Merge branch 'patch-19' of https://github.com/gjones2/Freeside
[freeside.git] / FS / FS / cdr.pm
1 package FS::cdr;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $DEBUG $me
5              $conf $cdr_prerate %cdr_prerate_cdrtypenums
6            );
7 use Exporter;
8 use List::Util qw(first min);
9 use Tie::IxHash;
10 use Date::Parse;
11 use Date::Format;
12 use Time::Local;
13 use List::Util qw( first min );
14 use Text::CSV_XS;
15 use FS::UID qw( dbh );
16 use FS::Conf;
17 use FS::Record qw( qsearch qsearchs );
18 use FS::cdr_type;
19 use FS::cdr_calltype;
20 use FS::cdr_carrier;
21 use FS::cdr_batch;
22 use FS::cdr_termination;
23 use FS::rate;
24 use FS::rate_prefix;
25 use FS::rate_detail;
26
27 @ISA = qw(FS::Record);
28 @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
29
30 $DEBUG = 0;
31 $me = '[FS::cdr]';
32
33 #ask FS::UID to run this stuff for us later
34 FS::UID->install_callback( sub { 
35   $conf = new FS::Conf;
36
37   my @cdr_prerate_cdrtypenums;
38   $cdr_prerate = $conf->exists('cdr-prerate');
39   @cdr_prerate_cdrtypenums = $conf->config('cdr-prerate-cdrtypenums')
40     if $cdr_prerate;
41   %cdr_prerate_cdrtypenums = map { $_=>1 } @cdr_prerate_cdrtypenums;
42 });
43
44 =head1 NAME
45
46 FS::cdr - Object methods for cdr records
47
48 =head1 SYNOPSIS
49
50   use FS::cdr;
51
52   $record = new FS::cdr \%hash;
53   $record = new FS::cdr { 'column' => 'value' };
54
55   $error = $record->insert;
56
57   $error = $new_record->replace($old_record);
58
59   $error = $record->delete;
60
61   $error = $record->check;
62
63 =head1 DESCRIPTION
64
65 An FS::cdr object represents an Call Data Record, typically from a telephony
66 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
67 following fields are currently supported:
68
69 =over 4
70
71 =item acctid - primary key
72
73 =item calldate - Call timestamp (SQL timestamp)
74
75 =item clid - Caller*ID with text
76
77 =item src - Caller*ID number / Source number
78
79 =item dst - Destination extension
80
81 =item dcontext - Destination context
82
83 =item channel - Channel used
84
85 =item dstchannel - Destination channel if appropriate
86
87 =item lastapp - Last application if appropriate
88
89 =item lastdata - Last application data
90
91 =item src_ip_addr - Source IP address (dotted quad, zero-filled)
92
93 =item dst_ip_addr - Destination IP address (same)
94
95 =item startdate - Start of call (UNIX-style integer timestamp)
96
97 =item answerdate - Answer time of call (UNIX-style integer timestamp)
98
99 =item enddate - End time of call (UNIX-style integer timestamp)
100
101 =item duration - Total time in system, in seconds
102
103 =item billsec - Total time call is up, in seconds
104
105 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
106
107 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
108
109 =cut
110
111   #ignore the "omit" and "documentation" AMAs??
112   #AMA = Automated Message Accounting. 
113   #default: Sets the system default. 
114   #omit: Do not record calls. 
115   #billing: Mark the entry for billing 
116   #documentation: Mark the entry for documentation.
117
118 =item accountcode - CDR account number to use: account
119
120 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
121
122 =item userfield - CDR user-defined field
123
124 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
125
126 =item charged_party - Service number to be billed
127
128 =item upstream_currency - Wholesale currency from upstream
129
130 =item upstream_price - Wholesale price from upstream
131
132 =item upstream_rateplanid - Upstream rate plan ID
133
134 =item rated_price - Rated (or re-rated) price
135
136 =item distance - km (need units field?)
137
138 =item islocal - Local - 1, Non Local = 0
139
140 =item calltypenum - Type of call - see L<FS::cdr_calltype>
141
142 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
143
144 =item quantity - Number of items (cdr_type 7&8 only)
145
146 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
147
148 =cut
149
150 #Telstra =1, Optus = 2, RSL COM = 3
151
152 =item upstream_rateid - Upstream Rate ID
153
154 =item svcnum - Link to customer service (see L<FS::cust_svc>)
155
156 =item freesidestatus - NULL, processing-tiered, rated, done, skipped, no-charge, failed
157
158 =item freesiderewritestatus - NULL, done, skipped
159
160 =item cdrbatch
161
162 =back
163
164 =head1 METHODS
165
166 =over 4
167
168 =item new HASHREF
169
170 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
171
172 Note that this stores the hash reference, not a distinct copy of the hash it
173 points to.  You can ask the object for a copy with the I<hash> method.
174
175 =cut
176
177 # the new method can be inherited from FS::Record, if a table method is defined
178
179 sub table { 'cdr'; }
180
181 sub table_info {
182   {
183     'fields' => {
184 #XXX fill in some (more) nice names
185         #'acctid'                => '',
186         'calldate'              => 'Call date',
187         'clid'                  => 'Caller ID',
188         'src'                   => 'Source',
189         'dst'                   => 'Destination',
190         'dcontext'              => 'Dest. context',
191         'channel'               => 'Channel',
192         'dstchannel'            => 'Destination channel',
193         #'lastapp'               => '',
194         #'lastdata'              => '',
195         'src_ip_addr'           => 'Source IP',
196         'dst_ip_addr'           => 'Dest. IP',
197         'startdate'             => 'Start date',
198         'answerdate'            => 'Answer date',
199         'enddate'               => 'End date',
200         'duration'              => 'Duration',
201         'billsec'               => 'Billable seconds',
202         'disposition'           => 'Disposition',
203         'amaflags'              => 'AMA flags',
204         'accountcode'           => 'Account code',
205         #'uniqueid'              => '',
206         'userfield'             => 'User field',
207         #'cdrtypenum'            => '',
208         'charged_party'         => 'Charged party',
209         #'upstream_currency'     => '',
210         'upstream_price'        => 'Upstream price',
211         #'upstream_rateplanid'   => '',
212         #'ratedetailnum'         => '',
213         'rated_price'           => 'Rated price',
214         #'distance'              => '',
215         #'islocal'               => '',
216         #'calltypenum'           => '',
217         #'description'           => '',
218         #'quantity'              => '',
219         'carrierid'             => 'Carrier ID',
220         #'upstream_rateid'       => '',
221         'svcnum'                => 'Freeside service',
222         'freesidestatus'        => 'Freeside status',
223         'freesiderewritestatus' => 'Freeside rewrite status',
224         'cdrbatch'              => 'Legacy batch',
225         'cdrbatchnum'           => 'Batch',
226     },
227
228   };
229
230 }
231
232 =item insert
233
234 Adds this record to the database.  If there is an error, returns the error,
235 otherwise returns false.
236
237 =cut
238
239 # the insert method can be inherited from FS::Record
240
241 =item delete
242
243 Delete this record from the database.
244
245 =cut
246
247 # the delete method can be inherited from FS::Record
248
249 =item replace OLD_RECORD
250
251 Replaces the OLD_RECORD with this one in the database.  If there is an error,
252 returns the error, otherwise returns false.
253
254 =cut
255
256 # the replace method can be inherited from FS::Record
257
258 =item check
259
260 Checks all fields to make sure this is a valid CDR.  If there is
261 an error, returns the error, otherwise returns false.  Called by the insert
262 and replace methods.
263
264 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
265 to process them as quickly as possible, so we allow the database to check most
266 of the data.
267
268 =cut
269
270 sub check {
271   my $self = shift;
272
273 # we don't want to "reject" a CDR like other sorts of input...
274 #  my $error = 
275 #    $self->ut_numbern('acctid')
276 ##    || $self->ut_('calldate')
277 #    || $self->ut_text('clid')
278 #    || $self->ut_text('src')
279 #    || $self->ut_text('dst')
280 #    || $self->ut_text('dcontext')
281 #    || $self->ut_text('channel')
282 #    || $self->ut_text('dstchannel')
283 #    || $self->ut_text('lastapp')
284 #    || $self->ut_text('lastdata')
285 #    || $self->ut_numbern('startdate')
286 #    || $self->ut_numbern('answerdate')
287 #    || $self->ut_numbern('enddate')
288 #    || $self->ut_number('duration')
289 #    || $self->ut_number('billsec')
290 #    || $self->ut_text('disposition')
291 #    || $self->ut_number('amaflags')
292 #    || $self->ut_text('accountcode')
293 #    || $self->ut_text('uniqueid')
294 #    || $self->ut_text('userfield')
295 #    || $self->ut_numbern('cdrtypenum')
296 #    || $self->ut_textn('charged_party')
297 ##    || $self->ut_n('upstream_currency')
298 ##    || $self->ut_n('upstream_price')
299 #    || $self->ut_numbern('upstream_rateplanid')
300 ##    || $self->ut_n('distance')
301 #    || $self->ut_numbern('islocal')
302 #    || $self->ut_numbern('calltypenum')
303 #    || $self->ut_textn('description')
304 #    || $self->ut_numbern('quantity')
305 #    || $self->ut_numbern('carrierid')
306 #    || $self->ut_numbern('upstream_rateid')
307 #    || $self->ut_numbern('svcnum')
308 #    || $self->ut_textn('freesidestatus')
309 #    || $self->ut_textn('freesiderewritestatus')
310 #  ;
311 #  return $error if $error;
312
313   for my $f ( grep { $self->$_ =~ /\D/ } qw(startdate answerdate enddate)){
314     $self->$f( str2time($self->$f) );
315   }
316
317   $self->calldate( $self->startdate_sql )
318     if !$self->calldate && $self->startdate;
319
320   #was just for $format eq 'taqua' but can't see the harm... add something to
321   #disable if it becomes a problem
322   if ( $self->duration eq '' && $self->enddate && $self->startdate ) {
323     $self->duration( $self->enddate - $self->startdate  );
324   }
325   if ( $self->billsec eq '' && $self->enddate && $self->answerdate ) {
326     $self->billsec(  $self->enddate - $self->answerdate );
327   } 
328
329   if ( ! $self->enddate && $self->startdate && $self->duration ) {
330     $self->enddate( $self->startdate + $self->duration );
331   }
332
333   $self->set_charged_party;
334
335   #check the foreign keys even?
336   #do we want to outright *reject* the CDR?
337   my $error =
338        $self->ut_numbern('acctid')
339
340   #add a config option to turn these back on if someone needs 'em
341   #
342   #  #Usage = 1, S&E = 7, OC&C = 8
343   #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
344   #
345   #  #the big list in appendix 2
346   #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
347   #
348   #  # Telstra =1, Optus = 2, RSL COM = 3
349   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
350   ;
351   return $error if $error;
352
353   $self->SUPER::check;
354 }
355
356 =item is_tollfree [ COLUMN ]
357
358 Returns true when the cdr represents a toll free number and false otherwise.
359
360 By default, inspects the dst field, but an optional column name can be passed
361 to inspect other field.
362
363 =cut
364
365 sub is_tollfree {
366   my $self = shift;
367   my $field = scalar(@_) ? shift : 'dst';
368   ( $self->$field() =~ /^(\+?1)?8(8|([02-7])\3)/ ) ? 1 : 0;
369 }
370
371 =item set_charged_party
372
373 If the charged_party field is already set, does nothing.  Otherwise:
374
375 If the cdr-charged_party-accountcode config option is enabled, sets the
376 charged_party to the accountcode.
377
378 Otherwise sets the charged_party normally: to the src field in most cases,
379 or to the dst field if it is a toll free number.
380
381 =cut
382
383 sub set_charged_party {
384   my $self = shift;
385
386   my $conf = new FS::Conf;
387
388   unless ( $self->charged_party ) {
389
390     if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
391
392       my $charged_party = $self->accountcode;
393       $charged_party =~ s/^0+//
394         if $conf->exists('cdr-charged_party-accountcode-trim_leading_0s');
395       $self->charged_party( $charged_party );
396
397     } elsif ( $conf->exists('cdr-charged_party-field') ) {
398
399       my $field = $conf->config('cdr-charged_party-field');
400       $self->charged_party( $self->$field() );
401
402     } else {
403
404       if ( $self->is_tollfree ) {
405         $self->charged_party($self->dst);
406       } else {
407         $self->charged_party($self->src);
408       }
409
410     }
411
412   }
413
414 #  my $prefix = $conf->config('cdr-charged_party-truncate_prefix');
415 #  my $prefix_len = length($prefix);
416 #  my $trunc_len = $conf->config('cdr-charged_party-truncate_length');
417 #
418 #  $self->charged_party( substr($self->charged_party, 0, $trunc_len) )
419 #    if $prefix_len && $trunc_len
420 #    && substr($self->charged_party, 0, $prefix_len) eq $prefix;
421
422 }
423
424 =item set_status STATUS
425
426 Sets the status to the provided string.  If there is an error, returns the
427 error, otherwise returns false.
428
429 If status is being changed from 'rated' to some other status, also removes
430 any usage allocations to this CDR.
431
432 =cut
433
434 sub set_status {
435   my($self, $status) = @_;
436   my $old_status = $self->freesidestatus;
437   $self->freesidestatus($status);
438   my $error = $self->replace;
439   if ( $old_status eq 'rated' and $status ne 'done' ) {
440     # deallocate any usage
441     foreach (qsearch('cdr_cust_pkg_usage', {acctid => $self->acctid})) {
442       my $cust_pkg_usage = $_->cust_pkg_usage;
443       $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $_->minutes);
444       $error ||= $cust_pkg_usage->replace || $_->delete;
445     }
446   }
447   $error;
448 }
449
450 =item set_status_and_rated_price STATUS RATED_PRICE [ SVCNUM [ OPTION => VALUE ... ] ]
451
452 Sets the status and rated price.
453
454 Available options are: inbound, rated_pretty_dst, rated_regionname,
455 rated_seconds, rated_minutes, rated_granularity, rated_ratedetailnum,
456 rated_classnum, rated_ratename.
457
458 If there is an error, returns the error, otherwise returns false.
459
460 =cut
461
462 sub set_status_and_rated_price {
463   my($self, $status, $rated_price, $svcnum, %opt) = @_;
464
465   if ($opt{'inbound'}) {
466
467     my $term = $self->cdr_termination( 1 ); #1: inbound
468     my $error;
469     if ( $term ) {
470       warn "replacing existing cdr status (".$self->acctid.")\n" if $term;
471       $error = $term->delete;
472       return $error if $error;
473     }
474     $term = FS::cdr_termination->new({
475         acctid      => $self->acctid,
476         termpart    => 1,
477         rated_price => $rated_price,
478         status      => $status,
479     });
480     $term->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds});
481     $term->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes});
482     $term->svcnum($svcnum) if $svcnum;
483     return $term->insert;
484
485   } else {
486
487     $self->freesidestatus($status);
488     $self->rated_price($rated_price);
489     $self->$_($opt{$_})
490       foreach grep exists($opt{$_}), map "rated_$_",
491         qw( pretty_dst regionname seconds minutes granularity
492             ratedetailnum classnum ratename );
493     $self->svcnum($svcnum) if $svcnum;
494     return $self->replace();
495
496   }
497 }
498
499 =item parse_number [ OPTION => VALUE ... ]
500
501 Returns two scalars, the countrycode and the rest of the number.
502
503 Options are passed as name-value pairs.  Currently available options are:
504
505 =over 4
506
507 =item column
508
509 The column containing the number to be parsed.  Defaults to dst.
510
511 =item international_prefix
512
513 The digits for international dialing.  Defaults to '011'  The value '+' is
514 always recognized.
515
516 =item domestic_prefix
517
518 The digits for domestic long distance dialing.  Defaults to '1'
519
520 =back
521
522 =cut
523
524 sub parse_number {
525   my ($self, %options) = @_;
526
527   my $field = $options{column} || 'dst';
528   my $intl = $options{international_prefix} || '011';
529   my $countrycode = '';
530   my $number = $self->$field();
531
532   my $to_or_from = 'concerning';
533   $to_or_from = 'from' if $field eq 'src';
534   $to_or_from = 'to' if $field eq 'dst';
535   warn "parsing call $to_or_from $number\n" if $DEBUG;
536
537   #remove non-phone# stuff and whitespace
538   $number =~ s/\s//g;
539 #          my $proto = '';
540 #          $dest =~ s/^(\w+):// and $proto = $1; #sip:
541 #          my $siphost = '';
542 #          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
543
544   if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
545        || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
546      )
547   {
548
549     my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
550     #first look for 1 digit country code
551     if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
552       $countrycode = $one;
553       $number = $u1.$u2.$rest;
554     } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
555       $countrycode = $two;
556       $number = $u2.$rest;
557     } else { #3 digit country code
558       $countrycode = $three;
559       $number = $rest;
560     }
561
562   } else {
563     my $domestic_prefix =
564       exists($options{domestic_prefix}) ? $options{domestic_prefix} : '';
565     $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
566     $number =~ s/^$countrycode//;# if length($number) > 10;
567   }
568
569   return($countrycode, $number);
570
571 }
572
573 =item rate [ OPTION => VALUE ... ]
574
575 Rates this CDR according and sets the status to 'rated'.
576
577 Available options are: part_pkg, svcnum, single_price_included_minutes, region_group, region_group_included_minutes.
578
579 part_pkg is required.
580
581 If svcnum is specified, will also associate this CDR with the specified svcnum.
582
583 single_price_included_minutes is requried for single_price price plans
584 (otherwise unused/ignored).  It should be set to a scalar reference of the
585 number of included minutes and will be decremented by the rated minutes of this
586 CDR.
587
588 region_group_included_minutes is required for prefix price plans which have
589 included minutes (otherwise unused/ignored).  It should be set to a scalar
590 reference of the number of included minutes and will be decremented by the
591 rated minutes of this CDR.
592
593 region_group_included_minutes_hashref is required for prefix price plans which
594 have included minues (otherwise unused/ignored).  It should be set to an empty
595 hashref at the start of a month's rating and then preserved across CDRs.
596
597 =cut
598
599 sub rate {
600   my( $self, %opt ) = @_;
601   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
602
603   if ( $DEBUG > 1 ) {
604     warn "rating CDR $self\n".
605          join('', map { "  $_ => ". $self->{$_}. "\n" } keys %$self );
606   }
607
608   my $rating_method = $part_pkg->option_cacheable('rating_method') || 'prefix';
609   my $method = "rate_$rating_method";
610   $self->$method(%opt);
611 }
612
613 #here?
614 our %interval_cache = (); # for timed rates
615
616 sub rate_prefix {
617   my( $self, %opt ) = @_;
618   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
619   my $cust_pkg = $opt{'cust_pkg'};
620
621   my $da_rewrote = 0;
622   # this will result in those CDRs being marked as done... is that 
623   # what we want?
624   my @dirass = ();
625   if ( $part_pkg->option_cacheable('411_rewrite') ) {
626     my $dirass = $part_pkg->option_cacheable('411_rewrite');
627     $dirass =~ s/\s//g;
628     @dirass = split(',', $dirass);
629   }
630
631   if ( length($self->dst) && grep { $self->dst eq $_ } @dirass ) {
632     $self->dst('411');
633     $da_rewrote = 1;
634   }
635
636   my $reason = $part_pkg->check_chargable( $self,
637                                            'da_rewrote'   => $da_rewrote,
638                                          );
639   if ( $reason ) {
640     warn "not charging for CDR ($reason)\n" if $DEBUG;
641     return $self->set_status_and_rated_price( 'skipped',
642                                               0,
643                                               $opt{'svcnum'},
644                                             );
645   }
646
647   if ( $part_pkg->option_cacheable('skip_same_customer')
648       and ! $self->is_tollfree ) {
649     my ($dst_countrycode, $dst_number) = $self->parse_number(
650       column => 'dst',
651       international_prefix => $part_pkg->option_cacheable('international_prefix'),
652       domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
653     );
654     my $dst_same_cust = FS::Record->scalar_sql(
655         'SELECT COUNT(svc_phone.svcnum) AS count '.
656         'FROM cust_pkg ' .
657         'JOIN cust_svc   USING (pkgnum) ' .
658         'JOIN svc_phone  USING (svcnum) ' .
659         'WHERE svc_phone.countrycode = ' . dbh->quote($dst_countrycode) .
660         ' AND svc_phone.phonenum = ' . dbh->quote($dst_number) .
661         ' AND cust_pkg.custnum = ' . $cust_pkg->custnum,
662     );
663     if ( $dst_same_cust > 0 ) {
664       warn "not charging for CDR (same source and destination customer)\n" if $DEBUG;
665       return $self->set_status_and_rated_price( 'skipped',
666                                                 0,
667                                                 $opt{'svcnum'},
668                                               );
669     }
670   }
671
672     
673
674
675   ###
676   # look up rate details based on called station id
677   # (or calling station id for toll free calls)
678   ###
679
680   my( $to_or_from, $column );
681   if ( $self->is_tollfree && ! $part_pkg->option_cacheable('disable_tollfree') )
682   { #tollfree call
683     $to_or_from = 'from';
684     $column = 'src';
685   } else { #regular call
686     $to_or_from = 'to';
687     $column = 'dst';
688   }
689
690   #determine the country code
691   my ($countrycode, $number) = $self->parse_number(
692     column => $column,
693     international_prefix => $part_pkg->option_cacheable('international_prefix'),
694     domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
695   );
696
697   warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG;
698   my $pretty_dst = "+$countrycode $number";
699   #asterisks here causes inserting the detail to barf, so:
700   $pretty_dst =~ s/\*//g;
701
702   my $eff_ratenum = $self->is_tollfree('accountcode')
703     ? $part_pkg->option_cacheable('accountcode_tollfree_ratenum')
704     : '';
705
706   my $ratename = '';
707   my $intrastate_ratenum = $part_pkg->option_cacheable('intrastate_ratenum');
708   if ( $intrastate_ratenum && !$self->is_tollfree ) {
709     $ratename = 'Interstate'; #until proven otherwise
710     # this is relatively easy only because:
711     # -assume all numbers are valid NANP numbers NOT in a fully-qualified format
712     # -disregard toll-free
713     # -disregard private or unknown numbers
714     # -there is exactly one record in rate_prefix for a given NPANXX
715     # -default to interstate if we can't find one or both of the prefixes
716     my (undef, $dstprefix) = $self->parse_number(
717       column => 'dst',
718       international_prefix => $part_pkg->option_cacheable('international_prefix'),
719       domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
720     );
721     $dstprefix =~ /^(\d{6})/;
722     $dstprefix = qsearchs('rate_prefix', {   'countrycode' => '1', 
723                                                 'npa' => $1, 
724                                          }) || '';
725     my (undef, $srcprefix) = $self->parse_number(
726       column => 'src',
727       international_prefix => $part_pkg->option_cacheable('international_prefix'),
728       domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
729     );
730     $srcprefix =~ /^(\d{6})/;
731     $srcprefix = qsearchs('rate_prefix', {   'countrycode' => '1',
732                                              'npa' => $1, 
733                                          }) || '';
734     if ($srcprefix && $dstprefix
735         && $srcprefix->state && $dstprefix->state
736         && $srcprefix->state eq $dstprefix->state) {
737       $eff_ratenum = $intrastate_ratenum;
738       $ratename = 'Intrastate'; # XXX possibly just use the ratename?
739     }
740   }
741
742   $eff_ratenum ||= $part_pkg->option_cacheable('ratenum');
743   my $rate = qsearchs('rate', { 'ratenum' => $eff_ratenum })
744     or die "ratenum $eff_ratenum not found!";
745
746   my @ltime = localtime($self->startdate);
747   my $weektime = $ltime[0] + 
748                  $ltime[1]*60 +   #minutes
749                  $ltime[2]*3600 + #hours
750                  $ltime[6]*86400; #days since sunday
751   # if there's no timed rate_detail for this time/region combination,
752   # dest_detail returns the default.  There may still be a timed rate 
753   # that applies after the starttime of the call, so be careful...
754   my $rate_detail = $rate->dest_detail({ 'countrycode' => $countrycode,
755                                          'phonenum'    => $number,
756                                          'weektime'    => $weektime,
757                                          'cdrtypenum'  => $self->cdrtypenum,
758                                       });
759
760   unless ( $rate_detail ) {
761
762     if ( $part_pkg->option_cacheable('ignore_unrateable') ) {
763
764       if ( $part_pkg->option_cacheable('ignore_unrateable') == 2 ) {
765         # mark the CDR as unrateable
766         return $self->set_status_and_rated_price(
767           'failed',
768           '',
769           $opt{'svcnum'},
770         );
771       } elsif ( $part_pkg->option_cacheable('ignore_unrateable') == 1 ) {
772         # warn and continue
773         warn "no rate_detail found for CDR.acctid: ". $self->acctid.
774              "; skipping\n";
775         return '';
776
777       } else {
778         die "unknown ignore_unrateable, pkgpart ". $part_pkg->pkgpart;
779       }
780
781     } else {
782
783       die "FATAL: no rate_detail found in ".
784           $rate->ratenum. ":". $rate->ratename. " rate plan ".
785           "for +$countrycode $number (CDR acctid ". $self->acctid. "); ".
786           "add a rate or set ignore_unrateable flag on the package def\n";
787     }
788
789   }
790
791   my $rate_region = $rate_detail->dest_region;
792   my $regionnum = $rate_region->regionnum;
793   warn "  found rate for regionnum $regionnum ".
794        "and rate detail $rate_detail\n"
795     if $DEBUG;
796
797   if ( !exists($interval_cache{$regionnum}) ) {
798     my @intervals = (
799       sort { $a->stime <=> $b->stime }
800         map { $_->rate_time->intervals }
801           qsearch({ 'table'     => 'rate_detail',
802                     'hashref'   => { 'ratenum' => $rate->ratenum },
803                     'extra_sql' => 'AND ratetimenum IS NOT NULL',
804                  })
805     );
806     $interval_cache{$regionnum} = \@intervals;
807     warn "  cached ".scalar(@intervals)." interval(s)\n"
808       if $DEBUG;
809   }
810
811   ###
812   # find the price and add detail to the invoice
813   ###
814
815   # About this section:
816   # We don't round _anything_ (except granularizing) 
817   # until the final $charge = sprintf("%.2f"...).
818
819   my $seconds_left = $part_pkg->option_cacheable('use_duration')
820                        ? $self->duration
821                        : $self->billsec;
822
823   #no, do this later so it respects (group) included minutes
824   #  # charge for the first (conn_sec) seconds
825   #  my $seconds = min($seconds_left, $rate_detail->conn_sec);
826   #  $seconds_left -= $seconds; 
827   #  $weektime     += $seconds;
828   #  my $charge = $rate_detail->conn_charge; 
829   my $seconds = 0;
830   my $charge = 0;
831   my $connection_charged = 0;
832
833   my $etime;
834   while($seconds_left) {
835     my $ratetimenum = $rate_detail->ratetimenum; # may be empty
836
837     # find the end of the current rate interval
838     if(@{ $interval_cache{$regionnum} } == 0) {
839       # There are no timed rates in this group, so just stay 
840       # in the default rate_detail for the entire duration.
841       # Set an "end" of 1 past the end of the current call.
842       $etime = $weektime + $seconds_left + 1;
843     } 
844     elsif($ratetimenum) {
845       # This is a timed rate, so go to the etime of this interval.
846       # If it's followed by another timed rate, the stime of that 
847       # interval should match the etime of this one.
848       my $interval = $rate_detail->rate_time->contains($weektime);
849       $etime = $interval->etime;
850     }
851     else {
852       # This is a default rate, so use the stime of the next 
853       # interval in the sequence.
854       my $next_int = first { $_->stime > $weektime } 
855                       @{ $interval_cache{$regionnum} };
856       if ($next_int) {
857         $etime = $next_int->stime;
858       }
859       else {
860         # weektime is near the end of the week, so decrement 
861         # it by a full week and use the stime of the first 
862         # interval.
863         $weektime -= (3600*24*7);
864         $etime = $interval_cache{$regionnum}->[0]->stime;
865       }
866     }
867
868     my $charge_sec = min($seconds_left, $etime - $weektime);
869
870     $seconds_left -= $charge_sec;
871
872     my $granularity = $rate_detail->sec_granularity;
873
874     my $minutes;
875     if ( $granularity ) { # charge per minute
876       # Round up to the nearest $granularity
877       if ( $charge_sec and $charge_sec % $granularity ) {
878         $charge_sec += $granularity - ($charge_sec % $granularity);
879       }
880       $minutes = $charge_sec / 60; #don't round this
881     }
882     else { # per call
883       $minutes = 1;
884       $seconds_left = 0;
885     }
886
887     $seconds += $charge_sec;
888
889     if ( $rate_detail->min_included ) {
890       # the old, kind of deprecated way to do this
891       my $included_min = $opt{'region_group_included_min_hashref'} || {};
892
893       # by default, set the included minutes for this region/time to
894       # what's in the rate_detail
895       $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
896         unless exists $included_min->{$regionnum}{$ratetimenum};
897
898       # the way that doesn't work
899       #my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0;
900
901       #${$opt{region_group_included_min}} -= $minutes 
902       #    if $region_group && $rate_detail->region_group;
903
904       if ( $included_min->{$regionnum}{$ratetimenum} > $minutes ) {
905         $charge_sec = 0;
906         $included_min->{$regionnum}{$ratetimenum} -= $minutes;
907       } else {
908         $charge_sec -= ($included_min->{$regionnum}{$ratetimenum} * 60);
909         $included_min->{$regionnum}{$ratetimenum} = 0;
910       }
911     } else {
912       # the new way!
913       my $applied_min = $cust_pkg->apply_usage(
914         'cdr'         => $self,
915         'rate_detail' => $rate_detail,
916         'minutes'     => $minutes
917       );
918       # for now, usage pools deal only in whole minutes
919       $charge_sec -= $applied_min * 60;
920     }
921
922     if ( $charge_sec > 0 ) {
923
924       #NOW do connection charges here... right?
925       #my $conn_seconds = min($seconds_left, $rate_detail->conn_sec);
926       my $conn_seconds = 0;
927       unless ( $connection_charged++ ) { #only one connection charge
928         $conn_seconds = min($charge_sec, $rate_detail->conn_sec);
929         $seconds_left -= $conn_seconds; 
930         $weektime     += $conn_seconds;
931         $charge += $rate_detail->conn_charge; 
932       }
933
934                            #should preserve (display?) this
935       my $charge_min = ( $charge_sec - $conn_seconds ) / 60;
936       $charge += ($rate_detail->min_charge * $charge_min) if $charge_min > 0; #still not rounded
937
938     }
939
940     # choose next rate_detail
941     $rate_detail = $rate->dest_detail({ 'countrycode' => $countrycode,
942                                         'phonenum'    => $number,
943                                         'weektime'    => $etime,
944                                         'cdrtypenum'  => $self->cdrtypenum })
945             if($seconds_left);
946     # we have now moved forward to $etime
947     $weektime = $etime;
948
949   } #while $seconds_left
950
951   # this is why we need regionnum/rate_region....
952   warn "  (rate region $rate_region)\n" if $DEBUG;
953
954   $self->set_status_and_rated_price(
955     'rated',
956     sprintf('%.2f', $charge + 0.000001), # NOW round it.
957     $opt{'svcnum'},
958     'rated_pretty_dst'    => $pretty_dst,
959     'rated_regionname'    => $rate_region->regionname,
960     'rated_seconds'       => $seconds,
961     'rated_granularity'   => $rate_detail->sec_granularity, #$granularity
962     'rated_ratedetailnum' => $rate_detail->ratedetailnum,
963     'rated_classnum'      => $rate_detail->classnum, #rated_ratedetailnum?
964     'rated_ratename'      => $ratename, #not rate_detail - Intrastate/Interstate
965   );
966
967 }
968
969 sub rate_upstream_simple {
970   my( $self, %opt ) = @_;
971
972   $self->set_status_and_rated_price(
973     'rated',
974     sprintf('%.3f', $self->upstream_price),
975     $opt{'svcnum'},
976     'rated_classnum' => $self->calltypenum,
977   );
978 }
979
980 sub rate_single_price {
981   my( $self, %opt ) = @_;
982   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
983
984   # a little false laziness w/abov
985   # $rate_detail = new FS::rate_detail({sec_granularity => ... }) ?
986
987   my $granularity = length($part_pkg->option_cacheable('sec_granularity'))
988                       ? $part_pkg->option_cacheable('sec_granularity')
989                       : 60;
990
991   my $seconds = $part_pkg->option_cacheable('use_duration')
992                   ? $self->duration
993                   : $self->billsec;
994
995   $seconds += $granularity - ( $seconds % $granularity )
996     if $seconds      # don't granular-ize 0 billsec calls (bills them)
997     && $granularity  # 0 is per call
998     && $seconds % $granularity;
999   my $minutes = $granularity ? ($seconds / 60) : 1;
1000
1001   my $charge_min = $minutes;
1002
1003   ${$opt{single_price_included_min}} -= $minutes;
1004   if ( ${$opt{single_price_included_min}} > 0 ) {
1005     $charge_min = 0;
1006   } else {
1007      $charge_min = 0 - ${$opt{single_price_included_min}};
1008      ${$opt{single_price_included_min}} = 0;
1009   }
1010
1011   my $charge =
1012     sprintf('%.4f', ( $part_pkg->option_cacheable('min_charge') * $charge_min )
1013                     + 0.0000000001 ); #so 1.00005 rounds to 1.0001
1014
1015   $self->set_status_and_rated_price(
1016     'rated',
1017     $charge,
1018     $opt{'svcnum'},
1019     'rated_granularity' => $granularity,
1020     'rated_seconds'     => $seconds,
1021   );
1022
1023 }
1024
1025 =item cdr_termination [ TERMPART ]
1026
1027 =cut
1028
1029 sub cdr_termination {
1030   my $self = shift;
1031
1032   if ( scalar(@_) && $_[0] ) {
1033     my $termpart = shift;
1034
1035     qsearchs('cdr_termination', { acctid   => $self->acctid,
1036                                   termpart => $termpart,
1037                                 }
1038             );
1039
1040   } else {
1041
1042     qsearch('cdr_termination', { acctid => $self->acctid, } );
1043
1044   }
1045
1046 }
1047
1048 =item calldate_unix 
1049
1050 Parses the calldate in SQL string format and returns a UNIX timestamp.
1051
1052 =cut
1053
1054 sub calldate_unix {
1055   str2time(shift->calldate);
1056 }
1057
1058 =item startdate_sql
1059
1060 Parses the startdate in UNIX timestamp format and returns a string in SQL
1061 format.
1062
1063 =cut
1064
1065 sub startdate_sql {
1066   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
1067   $mon++;
1068   $year += 1900;
1069   "$year-$mon-$mday $hour:$min:$sec";
1070 }
1071
1072 =item cdr_carrier
1073
1074 Returns the FS::cdr_carrier object associated with this CDR, or false if no
1075 carrierid is defined.
1076
1077 =cut
1078
1079 my %carrier_cache = ();
1080
1081 sub cdr_carrier {
1082   my $self = shift;
1083   return '' unless $self->carrierid;
1084   $carrier_cache{$self->carrierid} ||=
1085     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
1086 }
1087
1088 =item carriername 
1089
1090 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
1091 no FS::cdr_carrier object is assocated with this CDR.
1092
1093 =cut
1094
1095 sub carriername {
1096   my $self = shift;
1097   my $cdr_carrier = $self->cdr_carrier;
1098   $cdr_carrier ? $cdr_carrier->carriername : '';
1099 }
1100
1101 =item cdr_calltype
1102
1103 Returns the FS::cdr_calltype object associated with this CDR, or false if no
1104 calltypenum is defined.
1105
1106 =cut
1107
1108 my %calltype_cache = ();
1109
1110 sub cdr_calltype {
1111   my $self = shift;
1112   return '' unless $self->calltypenum;
1113   $calltype_cache{$self->calltypenum} ||=
1114     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
1115 }
1116
1117 =item calltypename 
1118
1119 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
1120 no FS::cdr_calltype object is assocated with this CDR.
1121
1122 =cut
1123
1124 sub calltypename {
1125   my $self = shift;
1126   my $cdr_calltype = $self->cdr_calltype;
1127   $cdr_calltype ? $cdr_calltype->calltypename : '';
1128 }
1129
1130 =item downstream_csv [ OPTION => VALUE, ... ]
1131
1132 =cut
1133
1134 my %export_names = (
1135   'simple'  => {
1136     'name'           => 'Simple',
1137     'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
1138   },
1139   'simple2' => {
1140     'name'           => 'Simple with source',
1141     'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
1142                        #"Date,Time,Name,Called From,Destination,Duration,Price",
1143   },
1144   'accountcode_simple' => {
1145     'name'           => 'Simple with accountcode',
1146     'invoice_header' => "Date,Time,Called From,Account,Duration,Price",
1147   },
1148   'basic' => {
1149     'name'           => 'Basic',
1150     'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
1151   },
1152   'default' => {
1153     'name'           => 'Default',
1154     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
1155   },
1156   'source_default' => {
1157     'name'           => 'Default with source',
1158     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
1159   },
1160   'accountcode_default' => {
1161     'name'           => 'Default plus accountcode',
1162     'invoice_header' => 'Date,Time,Account,Number,Destination,Duration,Price',
1163   },
1164   'description_default' => {
1165     'name'           => 'Default with description field as destination',
1166     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
1167   },
1168   'sum_duration' => {
1169     'name'           => 'Summary, one line per service',
1170     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
1171   },
1172   'sum_count' => {
1173     'name'           => 'Number of calls, one line per service',
1174     'invoice_header' => 'Caller,Rate,Messages,Price',
1175   },
1176   'sum_duration_prefix' => {
1177     'name'           => 'Summary, one line per destination prefix',
1178     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
1179   },
1180 );
1181
1182 my %export_formats = ();
1183 sub export_formats {
1184   #my $self = shift;
1185
1186   return %export_formats if keys %export_formats;
1187
1188   my $conf = new FS::Conf;
1189   my $date_format = $conf->config('date_format') || '%m/%d/%Y';
1190
1191   # call duration in the largest units that accurately reflect the  granularity
1192   my $duration_sub = sub {
1193     my($cdr, %opt) = @_;
1194     my $sec = $opt{seconds} || $cdr->billsec;
1195     if ( defined $opt{granularity} && 
1196          $opt{granularity} == 0 ) { #per call
1197       return '1 call';
1198     }
1199     elsif ( defined $opt{granularity} && $opt{granularity} == 60 ) {#full minutes
1200       my $min = int($sec/60);
1201       $min++ if $sec%60;
1202       return $min.'m';
1203     }
1204     else { #anything else
1205       return sprintf("%dm %ds", $sec/60, $sec%60);
1206     }
1207   };
1208
1209   my $price_sub = sub {
1210     my ($cdr, %opt) = @_;
1211     my $price;
1212     if ( defined($opt{charge}) ) {
1213       $price = $opt{charge};
1214     }
1215     elsif ( $opt{inbound} ) {
1216       my $term = $cdr->cdr_termination(1); # 1 = inbound
1217       $price = $term->rated_price if defined $term;
1218     }
1219     else {
1220       $price = $cdr->rated_price;
1221     }
1222     length($price) ? ($opt{money_char} . $price) : '';
1223   };
1224
1225   my $src_sub = sub { $_[0]->clid || $_[0]->src };
1226
1227   %export_formats = (
1228     'simple' => [
1229       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1230       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1231       'userfield',                                     #USER
1232       'dst',                                           #NUMBER_DIALED
1233       $duration_sub,                                   #DURATION
1234       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
1235       $price_sub,
1236     ],
1237     'simple2' => [
1238       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1239       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1240       #'userfield',                                     #USER
1241       $src_sub,                                           #called from
1242       'dst',                                           #NUMBER_DIALED
1243       $duration_sub,                                   #DURATION
1244       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
1245       $price_sub,
1246     ],
1247     'accountcode_simple' => [
1248       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1249       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1250       $src_sub,                                           #called from
1251       'accountcode',                                   #NUMBER_DIALED
1252       $duration_sub,                                   #DURATION
1253       $price_sub,
1254     ],
1255     'sum_duration' => [ 
1256       # for summary formats, the CDR is a fictitious object containing the 
1257       # total billsec and the phone number of the service
1258       $src_sub,
1259       sub { my($cdr, %opt) = @_; $opt{ratename} },
1260       sub { my($cdr, %opt) = @_; $opt{count} },
1261       sub { my($cdr, %opt) = @_; int($opt{seconds}/60).'m' },
1262       $price_sub,
1263     ],
1264     'sum_count' => [
1265       $src_sub,
1266       sub { my($cdr, %opt) = @_; $opt{ratename} },
1267       sub { my($cdr, %opt) = @_; $opt{count} },
1268       $price_sub,
1269     ],
1270     'basic' => [
1271       sub { time2str('%d %b - %I:%M %p', shift->calldate_unix) },
1272       'dst',
1273       $duration_sub,
1274       $price_sub,
1275     ],
1276     'default' => [
1277
1278       #DATE
1279       sub { time2str($date_format, shift->calldate_unix ) },
1280             # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
1281
1282       #TIME
1283       sub { time2str('%r', shift->calldate_unix ) },
1284             # time2str("%c", $cdr->calldate_unix),  #XXX this should probably be a config option dropdown so they can select US vs- rest of world dates or whatnot
1285
1286       #DEST ("Number")
1287       sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
1288
1289       #REGIONNAME ("Destination")
1290       sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
1291
1292       #DURATION
1293       $duration_sub,
1294
1295       #PRICE
1296       $price_sub,
1297     ],
1298   );
1299   $export_formats{'source_default'} = [ $src_sub, @{ $export_formats{'default'} }, ];
1300   $export_formats{'accountcode_default'} =
1301     [ @{ $export_formats{'default'} }[0,1],
1302       'accountcode',
1303       @{ $export_formats{'default'} }[2..5],
1304     ];
1305   my @default = @{ $export_formats{'default'} };
1306   $export_formats{'description_default'} = 
1307     [ $src_sub, @default[0..2], 
1308       sub { my($cdr, %opt) = @_; $cdr->description },
1309       @default[4,5] ];
1310
1311   return %export_formats;
1312 }
1313
1314 =item downstream_csv OPTION => VALUE ...
1315
1316 Returns a string of formatted call details for display on an invoice.
1317
1318 Options:
1319
1320 format
1321
1322 charge - override the 'rated_price' field of the CDR
1323
1324 seconds - override the 'billsec' field of the CDR
1325
1326 count - number of usage events included in this record, for summary formats
1327
1328 ratename - name of the rate table used to rate this call
1329
1330 granularity
1331
1332 =cut
1333
1334 sub downstream_csv {
1335   my( $self, %opt ) = @_;
1336
1337   my $format = $opt{'format'};
1338   my %formats = $self->export_formats;
1339   return "Unknown format $format" unless exists $formats{$format};
1340
1341   #my $conf = new FS::Conf;
1342   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
1343   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
1344
1345   my $csv = new Text::CSV_XS;
1346
1347   my @columns =
1348     map {
1349           ref($_) ? &{$_}($self, %opt) : $self->$_();
1350         }
1351     @{ $formats{$format} };
1352
1353   return @columns if defined $opt{'keeparray'};
1354
1355   my $status = $csv->combine(@columns);
1356   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
1357     unless $status;
1358
1359   $csv->string;
1360
1361 }
1362
1363 =back
1364
1365 =head1 CLASS METHODS
1366
1367 =over 4
1368
1369 =item invoice_formats
1370
1371 Returns an ordered list of key value pairs containing invoice format names
1372 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
1373
1374 =cut
1375
1376 # in the future, load this dynamically from detail_format classes
1377
1378 sub invoice_formats {
1379   map { ($_ => $export_names{$_}->{'name'}) }
1380     grep { $export_names{$_}->{'invoice_header'} }
1381     keys %export_names;
1382 }
1383
1384 =item invoice_header FORMAT
1385
1386 Returns a scalar containing the CSV column header for invoice format FORMAT.
1387
1388 =cut
1389
1390 sub invoice_header {
1391   my $format = shift;
1392   $export_names{$format}->{'invoice_header'};
1393 }
1394
1395 =item clear_status 
1396
1397 Clears cdr and any associated cdr_termination statuses - used for 
1398 CDR reprocessing.
1399
1400 =cut
1401
1402 sub clear_status {
1403   my $self = shift;
1404   my %opt = @_;
1405
1406   local $SIG{HUP} = 'IGNORE';
1407   local $SIG{INT} = 'IGNORE';
1408   local $SIG{QUIT} = 'IGNORE';
1409   local $SIG{TERM} = 'IGNORE';
1410   local $SIG{TSTP} = 'IGNORE';
1411   local $SIG{PIPE} = 'IGNORE';
1412
1413   my $oldAutoCommit = $FS::UID::AutoCommit;
1414   local $FS::UID::AutoCommit = 0;
1415   my $dbh = dbh;
1416
1417   if ( $cdr_prerate && $cdr_prerate_cdrtypenums{$self->cdrtypenum}
1418        && $self->rated_ratedetailnum #avoid putting old CDRs back in "rated"
1419        && $self->freesidestatus eq 'done'
1420        && ! $opt{'rerate'}
1421      )
1422   { #special case
1423     $self->freesidestatus('rated');
1424   } else {
1425     $self->freesidestatus('');
1426   }
1427
1428   my $error = $self->replace;
1429   if ( $error ) {
1430     $dbh->rollback if $oldAutoCommit;
1431     return $error;
1432   } 
1433
1434   foreach my $cdr_termination ( $self->cdr_termination ) {
1435       #$cdr_termination->status('');
1436       #$error = $cdr_termination->replace;
1437       $error = $cdr_termination->delete;
1438       if ( $error ) {
1439         $dbh->rollback if $oldAutoCommit;
1440         return $error;
1441       } 
1442   }
1443   
1444   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1445
1446   '';
1447 }
1448
1449 =item import_formats
1450
1451 Returns an ordered list of key value pairs containing import format names
1452 as keys (for use with batch_import) and "pretty" format names as values.
1453
1454 =cut
1455
1456 #false laziness w/part_pkg & part_export
1457
1458 my %cdr_info;
1459 foreach my $INC ( @INC ) {
1460   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
1461   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
1462     warn "attempting to load CDR format info from $file\n" if $DEBUG;
1463     $file =~ /\/(\w+)\.pm$/ or do {
1464       warn "unrecognized file in $INC/FS/cdr/: $file\n";
1465       next;
1466     };
1467     my $mod = $1;
1468     my $info = eval "use FS::cdr::$mod; ".
1469                     "\\%FS::cdr::$mod\::info;";
1470     if ( $@ ) {
1471       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
1472       next;
1473     }
1474     unless ( keys %$info ) {
1475       warn "no %info hash found in FS::cdr::$mod, skipping\n";
1476       next;
1477     }
1478     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
1479     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1480       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
1481       next;
1482     }
1483     $cdr_info{$mod} = $info;
1484   }
1485 }
1486
1487 tie my %import_formats, 'Tie::IxHash',
1488   map  { $_ => $cdr_info{$_}->{'name'} }
1489   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
1490   grep { exists($cdr_info{$_}->{'import_fields'}) }
1491   keys %cdr_info;
1492
1493 sub import_formats {
1494   %import_formats;
1495 }
1496
1497 sub _cdr_min_parser_maker {
1498   my $field = shift;
1499   my @fields = ref($field) ? @$field : ($field);
1500   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
1501   return sub {
1502     my( $cdr, $min ) = @_;
1503     my $sec = eval { _cdr_min_parse($min) };
1504     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
1505     $cdr->$_($sec) foreach @fields;
1506   };
1507 }
1508
1509 sub _cdr_min_parse {
1510   my $min = shift;
1511   sprintf('%.0f', $min * 60 );
1512 }
1513
1514 sub _cdr_date_parser_maker {
1515   my $field = shift;
1516   my %options = @_;
1517   my @fields = ref($field) ? @$field : ($field);
1518   return sub {
1519     my( $cdr, $datestring ) = @_;
1520     my $unixdate = eval { _cdr_date_parse($datestring, %options) };
1521     die "error parsing date for @fields from $datestring: $@\n" if $@;
1522     $cdr->$_($unixdate) foreach @fields;
1523   };
1524 }
1525
1526 sub _cdr_date_parse {
1527   my $date = shift;
1528   my %options = @_;
1529
1530   return '' unless length($date); #that's okay, it becomes NULL
1531   return '' if $date eq 'NA'; #sansay
1532
1533   if ( $date =~ /^([a-z]{3})\s+([a-z]{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s+(\d{4})$/i && $7 > 1970 ) {
1534     my $time = str2time($date);
1535     return $time if $time > 100000; #just in case
1536   }
1537
1538   my($year, $mon, $day, $hour, $min, $sec);
1539
1540   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
1541   #taqua  #2007-10-31 08:57:24.113000000
1542
1543   if ( $date =~ /^\s*(\d{4})\D(\d{1,2})\D(\d{1,2})\D+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
1544     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1545   } 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|$)/ ) {
1546     # 8/26/2010 12:20:01
1547     # optionally without seconds
1548     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1549     $sec = 0 if !defined($sec);
1550   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d+\.\d+)(\D|$)/ ) {
1551     # broadsoft: 20081223201938.314
1552     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1553   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\d+(\D|$)/ ) {
1554     # Taqua OM:  20050422203450943
1555     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1556   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ ) {
1557     # WIP: 20100329121420
1558     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1559   } elsif ( $date =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/) {
1560     # Telos
1561     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1562     $options{gmt} = 1;
1563   } else {
1564      die "unparsable date: $date"; #maybe we shouldn't die...
1565   }
1566
1567   return '' if ( $year == 1900 || $year == 1970 ) && $mon == 1 && $day == 1
1568             && $hour == 0 && $min == 0 && $sec == 0;
1569
1570   if ($options{gmt}) {
1571     timegm($sec, $min, $hour, $day, $mon-1, $year);
1572   } else {
1573     timelocal($sec, $min, $hour, $day, $mon-1, $year);
1574   }
1575 }
1576
1577 =item batch_import HASHREF
1578
1579 Imports CDR records.  Available options are:
1580
1581 =over 4
1582
1583 =item file
1584
1585 Filename
1586
1587 =item format
1588
1589 =item params
1590
1591 Hash reference of preset fields, typically cdrbatch
1592
1593 =item empty_ok
1594
1595 Set true to prevent throwing an error on empty imports
1596
1597 =back
1598
1599 =cut
1600
1601 my %import_options = (
1602   'table'         => 'cdr',
1603
1604   'batch_keycol'  => 'cdrbatchnum',
1605   'batch_table'   => 'cdr_batch',
1606   'batch_namecol' => 'cdrbatch',
1607
1608   'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
1609                      keys %cdr_info
1610                },
1611
1612                           #drop the || 'csv' to allow auto xls for csv types?
1613   'format_types' => { map { $_ => lc($cdr_info{$_}->{'type'} || 'csv'); }
1614                           keys %cdr_info
1615                     },
1616
1617   'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
1618                             keys %cdr_info
1619                       },
1620
1621   'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
1622                               keys %cdr_info
1623                         },
1624
1625   'format_fixedlength_formats' =>
1626     { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
1627           keys %cdr_info
1628     },
1629
1630   'format_xml_formats' =>
1631     { map { $_ => $cdr_info{$_}->{'xml_format'}; }
1632           keys %cdr_info
1633     },
1634
1635   'format_asn_formats' =>
1636     { map { $_ => $cdr_info{$_}->{'asn_format'}; }
1637           keys %cdr_info
1638     },
1639
1640   'format_row_callbacks' => { map { $_ => $cdr_info{$_}->{'row_callback'}; }
1641                                   keys %cdr_info
1642                             },
1643 );
1644
1645 sub _import_options {
1646   \%import_options;
1647 }
1648
1649 sub batch_import {
1650   my $opt = shift;
1651
1652   my $iopt = _import_options;
1653   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
1654
1655   if ( defined $opt->{'cdrtypenum'} ) {
1656         $opt->{'preinsert_callback'} = sub {
1657                 my($record,$param) = (shift,shift);
1658                 $record->cdrtypenum($opt->{'cdrtypenum'});
1659                 '';
1660         };
1661   }
1662
1663   FS::Record::batch_import( $opt );
1664
1665 }
1666
1667 =item process_batch_import
1668
1669 =cut
1670
1671 sub process_batch_import {
1672   my $job = shift;
1673
1674   my $opt = _import_options;
1675 #  $opt->{'params'} = [ 'format', 'cdrbatch' ];
1676
1677   FS::Record::process_batch_import( $job, $opt, @_ );
1678
1679 }
1680 #  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
1681 #    @columns = map { s/^ +//; $_; } @columns;
1682 #  }
1683
1684 # _ upgrade_data
1685 #
1686 # Used by FS::Upgrade to migrate to a new database.
1687
1688 sub _upgrade_data {
1689   my ($class, %opts) = @_;
1690
1691   warn "$me upgrading $class\n" if $DEBUG;
1692
1693   my $sth = dbh->prepare(
1694     'SELECT DISTINCT(cdrbatch) FROM cdr WHERE cdrbatch IS NOT NULL'
1695   ) or die dbh->errstr;
1696
1697   $sth->execute or die $sth->errstr;
1698
1699   my %cdrbatchnum = ();
1700   while (my $row = $sth->fetchrow_arrayref) {
1701
1702     my $cdr_batch = qsearchs( 'cdr_batch', { 'cdrbatch' => $row->[0] } );
1703     unless ( $cdr_batch ) {
1704       $cdr_batch = new FS::cdr_batch { 'cdrbatch' => $row->[0] };
1705       my $error = $cdr_batch->insert;
1706       die $error if $error;
1707     }
1708
1709     $cdrbatchnum{$row->[0]} = $cdr_batch->cdrbatchnum;
1710   }
1711
1712   $sth = dbh->prepare('UPDATE cdr SET cdrbatch = NULL, cdrbatchnum = ? WHERE cdrbatch IS NOT NULL AND cdrbatch = ?') or die dbh->errstr;
1713
1714   foreach my $cdrbatch (keys %cdrbatchnum) {
1715     $sth->execute($cdrbatchnum{$cdrbatch}, $cdrbatch) or die $sth->errstr;
1716   }
1717
1718 }
1719
1720 =item ip_addr_sql FIELD RANGE
1721
1722 Returns an SQL condition to search for CDRs with an IP address 
1723 within RANGE.  FIELD is either 'src_ip_addr' or 'dst_ip_addr'.  RANGE 
1724 should be in the form "a.b.c.d-e.f.g.h' (dotted quads), where any of 
1725 the leftmost octets of the second address can be omitted if they're 
1726 the same as the first address.
1727
1728 =cut
1729
1730 sub ip_addr_sql {
1731   my $class = shift;
1732   my ($field, $range) = @_;
1733   $range =~ /^[\d\.-]+$/ or die "bad ip address range '$range'";
1734   my @r = split('-', $range);
1735   my @saddr = split('\.', $r[0] || '');
1736   my @eaddr = split('\.', $r[1] || '');
1737   unshift @eaddr, (undef) x (4 - scalar @eaddr);
1738   for(0..3) {
1739     $eaddr[$_] = $saddr[$_] if !defined $eaddr[$_];
1740   }
1741   "$field >= '".sprintf('%03d.%03d.%03d.%03d', @saddr) . "' AND ".
1742   "$field <= '".sprintf('%03d.%03d.%03d.%03d', @eaddr) . "'";
1743 }
1744
1745 =back
1746
1747 =head1 BUGS
1748
1749 =head1 SEE ALSO
1750
1751 L<FS::Record>, schema.html from the base documentation.
1752
1753 =cut
1754
1755 1;
1756