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