set charged_party to accoutncode for vedeye, RT#4342
[freeside.git] / FS / FS / cdr.pm
1 package FS::cdr;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Tie::IxHash;
7 use Date::Parse;
8 use Date::Format;
9 use Time::Local;
10 use FS::UID qw( dbh );
11 use FS::Record qw( qsearch qsearchs );
12 use FS::cdr_type;
13 use FS::cdr_calltype;
14 use FS::cdr_carrier;
15 use FS::cdr_upstream_rate;
16
17 @ISA = qw(FS::Record);
18 @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
19
20 $DEBUG = 0;
21
22 =head1 NAME
23
24 FS::cdr - Object methods for cdr records
25
26 =head1 SYNOPSIS
27
28   use FS::cdr;
29
30   $record = new FS::cdr \%hash;
31   $record = new FS::cdr { 'column' => 'value' };
32
33   $error = $record->insert;
34
35   $error = $new_record->replace($old_record);
36
37   $error = $record->delete;
38
39   $error = $record->check;
40
41 =head1 DESCRIPTION
42
43 An FS::cdr object represents an Call Data Record, typically from a telephony
44 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
45 following fields are currently supported:
46
47 =over 4
48
49 =item acctid - primary key
50
51 =item calldate - Call timestamp (SQL timestamp)
52
53 =item clid - Caller*ID with text
54
55 =item src - Caller*ID number / Source number
56
57 =item dst - Destination extension
58
59 =item dcontext - Destination context
60
61 =item channel - Channel used
62
63 =item dstchannel - Destination channel if appropriate
64
65 =item lastapp - Last application if appropriate
66
67 =item lastdata - Last application data
68
69 =item startdate - Start of call (UNIX-style integer timestamp)
70
71 =item answerdate - Answer time of call (UNIX-style integer timestamp)
72
73 =item enddate - End time of call (UNIX-style integer timestamp)
74
75 =item duration - Total time in system, in seconds
76
77 =item billsec - Total time call is up, in seconds
78
79 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
80
81 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
82
83 =cut
84
85   #ignore the "omit" and "documentation" AMAs??
86   #AMA = Automated Message Accounting. 
87   #default: Sets the system default. 
88   #omit: Do not record calls. 
89   #billing: Mark the entry for billing 
90   #documentation: Mark the entry for documentation.
91
92 =item accountcode - CDR account number to use: account
93
94 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
95
96 =item userfield - CDR user-defined field
97
98 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
99
100 =item charged_party - Service number to be billed
101
102 =item upstream_currency - Wholesale currency from upstream
103
104 =item upstream_price - Wholesale price from upstream
105
106 =item upstream_rateplanid - Upstream rate plan ID
107
108 =item rated_price - Rated (or re-rated) price
109
110 =item distance - km (need units field?)
111
112 =item islocal - Local - 1, Non Local = 0
113
114 =item calltypenum - Type of call - see L<FS::cdr_calltype>
115
116 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
117
118 =item quantity - Number of items (cdr_type 7&8 only)
119
120 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
121
122 =cut
123
124 #Telstra =1, Optus = 2, RSL COM = 3
125
126 =item upstream_rateid - Upstream Rate ID
127
128 =item svcnum - Link to customer service (see L<FS::cust_svc>)
129
130 =item freesidestatus - NULL, done (or something)
131
132 =item cdrbatch
133
134 =back
135
136 =head1 METHODS
137
138 =over 4
139
140 =item new HASHREF
141
142 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
143
144 Note that this stores the hash reference, not a distinct copy of the hash it
145 points to.  You can ask the object for a copy with the I<hash> method.
146
147 =cut
148
149 # the new method can be inherited from FS::Record, if a table method is defined
150
151 sub table { 'cdr'; }
152
153 =item insert
154
155 Adds this record to the database.  If there is an error, returns the error,
156 otherwise returns false.
157
158 =cut
159
160 # the insert method can be inherited from FS::Record
161
162 =item delete
163
164 Delete this record from the database.
165
166 =cut
167
168 # the delete method can be inherited from FS::Record
169
170 =item replace OLD_RECORD
171
172 Replaces the OLD_RECORD with this one in the database.  If there is an error,
173 returns the error, otherwise returns false.
174
175 =cut
176
177 # the replace method can be inherited from FS::Record
178
179 =item check
180
181 Checks all fields to make sure this is a valid CDR.  If there is
182 an error, returns the error, otherwise returns false.  Called by the insert
183 and replace methods.
184
185 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
186 to process them as quickly as possible, so we allow the database to check most
187 of the data.
188
189 =cut
190
191 sub check {
192   my $self = shift;
193
194 # we don't want to "reject" a CDR like other sorts of input...
195 #  my $error = 
196 #    $self->ut_numbern('acctid')
197 ##    || $self->ut_('calldate')
198 #    || $self->ut_text('clid')
199 #    || $self->ut_text('src')
200 #    || $self->ut_text('dst')
201 #    || $self->ut_text('dcontext')
202 #    || $self->ut_text('channel')
203 #    || $self->ut_text('dstchannel')
204 #    || $self->ut_text('lastapp')
205 #    || $self->ut_text('lastdata')
206 #    || $self->ut_numbern('startdate')
207 #    || $self->ut_numbern('answerdate')
208 #    || $self->ut_numbern('enddate')
209 #    || $self->ut_number('duration')
210 #    || $self->ut_number('billsec')
211 #    || $self->ut_text('disposition')
212 #    || $self->ut_number('amaflags')
213 #    || $self->ut_text('accountcode')
214 #    || $self->ut_text('uniqueid')
215 #    || $self->ut_text('userfield')
216 #    || $self->ut_numbern('cdrtypenum')
217 #    || $self->ut_textn('charged_party')
218 ##    || $self->ut_n('upstream_currency')
219 ##    || $self->ut_n('upstream_price')
220 #    || $self->ut_numbern('upstream_rateplanid')
221 ##    || $self->ut_n('distance')
222 #    || $self->ut_numbern('islocal')
223 #    || $self->ut_numbern('calltypenum')
224 #    || $self->ut_textn('description')
225 #    || $self->ut_numbern('quantity')
226 #    || $self->ut_numbern('carrierid')
227 #    || $self->ut_numbern('upstream_rateid')
228 #    || $self->ut_numbern('svcnum')
229 #    || $self->ut_textn('freesidestatus')
230 #  ;
231 #  return $error if $error;
232
233   $self->calldate( $self->startdate_sql )
234     if !$self->calldate && $self->startdate;
235
236   my $conf = new FS::Conf;
237
238   unless ( $self->charged_party ) {
239
240     if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
241
242       $self->charged_party( $self->accountcode );
243
244     } else {
245
246       if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
247         $self->charged_party($self->dst);
248       } else {
249         $self->charged_party($self->src);
250       }
251
252     }
253
254   }
255
256   #check the foreign keys even?
257   #do we want to outright *reject* the CDR?
258   my $error =
259        $self->ut_numbern('acctid')
260
261   #add a config option to turn these back on if someone needs 'em
262   #
263   #  #Usage = 1, S&E = 7, OC&C = 8
264   #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
265   #
266   #  #the big list in appendix 2
267   #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
268   #
269   #  # Telstra =1, Optus = 2, RSL COM = 3
270   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
271   ;
272   return $error if $error;
273
274   $self->SUPER::check;
275 }
276
277 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
278
279 Sets the status to the provided string.  If there is an error, returns the
280 error, otherwise returns false.
281
282 =cut
283
284 sub set_status_and_rated_price {
285   my($self, $status, $rated_price) = @_;
286   $self->freesidestatus($status);
287   $self->rated_price($rated_price);
288   $self->replace();
289 }
290
291 =item calldate_unix 
292
293 Parses the calldate in SQL string format and returns a UNIX timestamp.
294
295 =cut
296
297 sub calldate_unix {
298   str2time(shift->calldate);
299 }
300
301 =item startdate_sql
302
303 Parses the startdate in UNIX timestamp format and returns a string in SQL
304 format.
305
306 =cut
307
308 sub startdate_sql {
309   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
310   $mon++;
311   $year += 1900;
312   "$year-$mon-$mday $hour:$min:$sec";
313 }
314
315 =item cdr_carrier
316
317 Returns the FS::cdr_carrier object associated with this CDR, or false if no
318 carrierid is defined.
319
320 =cut
321
322 my %carrier_cache = ();
323
324 sub cdr_carrier {
325   my $self = shift;
326   return '' unless $self->carrierid;
327   $carrier_cache{$self->carrierid} ||=
328     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
329 }
330
331 =item carriername 
332
333 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
334 no FS::cdr_carrier object is assocated with this CDR.
335
336 =cut
337
338 sub carriername {
339   my $self = shift;
340   my $cdr_carrier = $self->cdr_carrier;
341   $cdr_carrier ? $cdr_carrier->carriername : '';
342 }
343
344 =item cdr_calltype
345
346 Returns the FS::cdr_calltype object associated with this CDR, or false if no
347 calltypenum is defined.
348
349 =cut
350
351 my %calltype_cache = ();
352
353 sub cdr_calltype {
354   my $self = shift;
355   return '' unless $self->calltypenum;
356   $calltype_cache{$self->calltypenum} ||=
357     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
358 }
359
360 =item calltypename 
361
362 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
363 no FS::cdr_calltype object is assocated with this CDR.
364
365 =cut
366
367 sub calltypename {
368   my $self = shift;
369   my $cdr_calltype = $self->cdr_calltype;
370   $cdr_calltype ? $cdr_calltype->calltypename : '';
371 }
372
373 =item cdr_upstream_rate
374
375 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
376 string if no FS::cdr_upstream_rate object is associated with this CDR.
377
378 =cut
379
380 sub cdr_upstream_rate {
381   my $self = shift;
382   return '' unless $self->upstream_rateid;
383   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
384     or '';
385 }
386
387 =item _convergent_format COLUMN [ COUNTRYCODE ]
388
389 Returns the number in COLUMN formatted as follows:
390
391 If the country code does not match COUNTRYCODE (default "61"), it is returned
392 unchanged.
393
394 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
395 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
396
397 =cut
398
399 sub _convergent_format {
400   my( $self, $field ) = ( shift, shift );
401   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
402   #my $number = $self->$field();
403   my $number = $self->get($field);
404   #if ( $number =~ s/^(\+|011)$countrycode// ) {
405   if ( $number =~ s/^\+$countrycode// ) {
406     $number = "0$number"
407       unless $number =~ /^1[389]/; #???
408   }
409   $number;
410 }
411
412 =item downstream_csv [ OPTION => VALUE, ... ]
413
414 =cut
415
416 my %export_names = (
417   'convergent'      => {},
418   'simple'  => { 'name'           => 'Simple',
419                  'invoice_header' =>
420                      "Date,Time,Name,Destination,Duration,Price",
421                },
422   'simple2' => { 'name'           => 'Simple with source',
423                  'invoice_header' =>
424                      #"Date,Time,Name,Called From,Destination,Duration,Price",
425                      "Date,Time,Called From,Destination,Duration,Price",
426                },
427 );
428
429 my %export_formats = (
430   'convergent' => [
431     'carriername', #CARRIER
432     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
433     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
434     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
435     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
436     'billsec', #'duration', #DURATION
437     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
438     '', #XXX add (from prefixes in most recent email) #FROM_DESC
439     '', #XXX add (from prefixes in most recent email) #TO_DESC
440     'calltypename', #CLASS_CODE
441     'rated_price', #PRICE
442     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
443     '', #OTHER_INFO
444   ],
445   'simple' => [
446     sub { time2str('%D', shift->calldate_unix ) },   #DATE
447     sub { time2str('%r', shift->calldate_unix ) },   #TIME
448     'userfield',                                     #USER
449     'dst',                                           #NUMBER_DIALED
450     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
451     sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
452   ],
453   'simple2' => [
454     sub { time2str('%D', shift->calldate_unix ) },   #DATE
455     sub { time2str('%r', shift->calldate_unix ) },   #TIME
456     #'userfield',                                     #USER
457     'dst',                                           #NUMBER_DIALED
458     'src',                                           #called from
459     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
460     sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
461   ],
462 );
463
464 sub downstream_csv {
465   my( $self, %opt ) = @_;
466
467   my $format = $opt{'format'}; # 'convergent';
468   return "Unknown format $format" unless exists $export_formats{$format};
469
470   eval "use Text::CSV_XS;";
471   die $@ if $@;
472   my $csv = new Text::CSV_XS;
473
474   my @columns =
475     map {
476           ref($_) ? &{$_}($self) : $self->$_();
477         }
478     @{ $export_formats{$format} };
479
480   my $status = $csv->combine(@columns);
481   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
482     unless $status;
483
484   $csv->string;
485
486 }
487
488 =back
489
490 =head1 CLASS METHODS
491
492 =over 4
493
494 =item invoice_formats
495
496 Returns an ordered list of key value pairs containing invoice format names
497 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
498
499 =cut
500
501 sub invoice_formats {
502   map { ($_ => $export_names{$_}->{'name'}) }
503     grep { $export_names{$_}->{'invoice_header'} }
504     keys %export_names;
505 }
506
507 =item invoice_header FORMAT
508
509 Returns a scalar containing the CSV column header for invoice format FORMAT.
510
511 =cut
512
513 sub invoice_header {
514   my $format = shift;
515   $export_names{$format}->{'invoice_header'};
516 }
517
518 =item import_formats
519
520 Returns an ordered list of key value pairs containing import format names
521 as keys (for use with batch_import) and "pretty" format names as values.
522
523 =cut
524
525 #false laziness w/part_pkg & part_export
526
527 my %cdr_info;
528 foreach my $INC ( @INC ) {
529   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
530   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
531     warn "attempting to load CDR format info from $file\n" if $DEBUG;
532     $file =~ /\/(\w+)\.pm$/ or do {
533       warn "unrecognized file in $INC/FS/cdr/: $file\n";
534       next;
535     };
536     my $mod = $1;
537     my $info = eval "use FS::cdr::$mod; ".
538                     "\\%FS::cdr::$mod\::info;";
539     if ( $@ ) {
540       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
541       next;
542     }
543     unless ( keys %$info ) {
544       warn "no %info hash found in FS::cdr::$mod, skipping\n";
545       next;
546     }
547     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
548     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
549       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
550       next;
551     }
552     $cdr_info{$mod} = $info;
553   }
554 }
555
556 tie my %import_formats, 'Tie::IxHash',
557   map  { $_ => $cdr_info{$_}->{'name'} }
558   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
559   grep { exists($cdr_info{$_}->{'import_fields'}) }
560   keys %cdr_info;
561
562 sub import_formats {
563   %import_formats;
564 }
565
566 sub _cdr_min_parser_maker {
567   my $field = shift;
568   my @fields = ref($field) ? @$field : ($field);
569   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
570   return sub {
571     my( $cdr, $min ) = @_;
572     my $sec = eval { _cdr_min_parse($min) };
573     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
574     $cdr->$_($sec) foreach @fields;
575   };
576 }
577
578 sub _cdr_min_parse {
579   my $min = shift;
580   sprintf('%.0f', $min * 60 );
581 }
582
583 sub _cdr_date_parser_maker {
584   my $field = shift;
585   return sub {
586     my( $cdr, $date ) = @_;
587     #$cdr->$field( _cdr_date_parse($date) );
588     eval { $cdr->$field( _cdr_date_parse($date) ); };
589     die "error parsing date for $field from $date: $@\n" if $@;
590   };
591 }
592
593 sub _cdr_date_parse {
594   my $date = shift;
595
596   return '' unless length($date); #that's okay, it becomes NULL
597
598   my($year, $mon, $day, $hour, $min, $sec);
599
600   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
601   #taqua  #2007-10-31 08:57:24.113000000
602
603   if ( $date =~ /^\s*(\d{4})\D(\d{1,2})\D(\d{1,2})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
604     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
605   } elsif ( $date  =~ /^\s*(\d{1,2})\D(\d{1,2})\D(\d{4})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
606     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
607   } else {
608      die "unparsable date: $date"; #maybe we shouldn't die...
609   }
610
611   return '' if $year == 1900 && $mon == 1 && $day == 1
612             && $hour == 0    && $min == 0 && $sec == 0;
613
614   timelocal($sec, $min, $hour, $day, $mon-1, $year);
615 }
616
617 =item batch_import HASHREF
618
619 Imports CDR records.  Available options are:
620
621 =over 4
622
623 =item filehandle
624
625 =item format
626
627 =back
628
629 =cut
630
631 sub batch_import {
632   my $param = shift;
633
634   my $fh = $param->{filehandle};
635   my $format = $param->{format};
636   my $cdrbatch = $param->{cdrbatch};
637
638   return "Unknown format $format"
639     unless exists( $cdr_info{$format} )
640         && exists( $cdr_info{$format}->{'import_fields'} );
641
642   my $info = $cdr_info{$format};
643
644   my $type = exists($info->{'type'}) ? lc($info->{'type'}) : 'csv';
645
646   my $parser;
647   if ( $type eq 'csv' ) {
648     eval "use Text::CSV_XS;";
649     die $@ if $@;
650     my %attr = ();
651     foreach ( grep exists($info->{$_}), qw( sep_char ) ) {
652       $attr{$_} = $info->{$_};
653     }
654     $parser = new Text::CSV_XS \%attr;
655   } elsif ( $type eq 'fixedlength' ) {
656     eval "use Parse::FixedLength;";
657     die $@ if $@;
658     $parser = new Parse::FixedLength $info->{'fixedlength_format'};
659   } else {
660     die "Unknown CDR format type $type for format $format\n";
661   }
662
663   my $imported = 0;
664   #my $columns;
665
666   local $SIG{HUP} = 'IGNORE';
667   local $SIG{INT} = 'IGNORE';
668   local $SIG{QUIT} = 'IGNORE';
669   local $SIG{TERM} = 'IGNORE';
670   local $SIG{TSTP} = 'IGNORE';
671   local $SIG{PIPE} = 'IGNORE';
672
673   my $oldAutoCommit = $FS::UID::AutoCommit;
674   local $FS::UID::AutoCommit = 0;
675   my $dbh = dbh;
676
677   my $header_lines = exists($info->{'header'}) ? $info->{'header'} : 0;
678
679   my $line;
680   while ( defined($line=<$fh>) ) {
681
682     next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/ 
683
684     my @columns = ();
685     if ( $type eq 'csv' ) {
686
687       $parser->parse($line) or do {
688         $dbh->rollback if $oldAutoCommit;
689         return "can't parse: ". $parser->error_input();
690       };
691
692       @columns = $parser->fields();
693
694     } elsif ( $type eq 'fixedlength' ) {
695
696       @columns = $parser->parse($line);
697
698     } else {
699       die "Unknown CDR format type $type for format $format\n";
700     }
701
702     #warn join('-',@columns);
703
704     if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
705       @columns = map { s/^ +//; $_; } @columns;
706     }
707
708     my @later = ();
709     my %cdr =
710       map {
711
712         my $field_or_sub = $_;
713         if ( ref($field_or_sub) ) {
714           push @later, $field_or_sub, shift(@columns);
715           ();
716         } else {
717           ( $field_or_sub => shift @columns );
718         }
719
720       }
721       @{ $info->{'import_fields'} }
722     ;
723  
724     $cdr{cdrbatch} = $cdrbatch;
725
726     my $cdr = new FS::cdr ( \%cdr );
727
728     while ( scalar(@later) ) {
729       my $sub = shift @later;
730       my $data = shift @later;
731       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
732     }
733
734     if ( $format eq 'taqua' ) { #should be a callback or opt in FS::cdr::taqua
735       if ( $cdr->enddate && $cdr->startdate  ) { #a bit more?
736         $cdr->duration( $cdr->enddate - $cdr->startdate  );
737       }
738       if ( $cdr->enddate && $cdr->answerdate ) { #a bit more?
739         $cdr->billsec(  $cdr->enddate - $cdr->answerdate );
740       } 
741     }
742
743     my $error = $cdr->insert;
744     if ( $error ) {
745       $dbh->rollback if $oldAutoCommit;
746       return $error;
747
748       #or just skip?
749       #next;
750     }
751
752     $imported++;
753   }
754
755   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
756
757   #might want to disable this if we skip records for any reason...
758   return "Empty file!" unless $imported || $param->{empty_ok};
759
760   '';
761
762 }
763
764 =back
765
766 =head1 BUGS
767
768 =head1 SEE ALSO
769
770 L<FS::Record>, schema.html from the base documentation.
771
772 =cut
773
774 1;
775