fix for flat-rate prefix rating, #24716, from #5738
[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       if ( $granularity == 0 ) { # per call rate
936         $charge += $rate_detail->min_charge;
937       } else {
938         my $charge_min = ( $charge_sec - $conn_seconds ) / 60;
939         $charge += ($rate_detail->min_charge * $charge_min) if $charge_min > 0; #still not rounded
940       }
941
942     }
943
944     # choose next rate_detail
945     $rate_detail = $rate->dest_detail({ 'countrycode' => $countrycode,
946                                         'phonenum'    => $number,
947                                         'weektime'    => $etime,
948                                         'cdrtypenum'  => $self->cdrtypenum })
949             if($seconds_left);
950     # we have now moved forward to $etime
951     $weektime = $etime;
952
953   } #while $seconds_left
954
955   # this is why we need regionnum/rate_region....
956   warn "  (rate region $rate_region)\n" if $DEBUG;
957
958   $self->set_status_and_rated_price(
959     'rated',
960     sprintf('%.2f', $charge + 0.000001), # NOW round it.
961     $opt{'svcnum'},
962     'rated_pretty_dst'    => $pretty_dst,
963     'rated_regionname'    => $rate_region->regionname,
964     'rated_seconds'       => $seconds,
965     'rated_granularity'   => $rate_detail->sec_granularity, #$granularity
966     'rated_ratedetailnum' => $rate_detail->ratedetailnum,
967     'rated_classnum'      => $rate_detail->classnum, #rated_ratedetailnum?
968     'rated_ratename'      => $ratename, #not rate_detail - Intrastate/Interstate
969   );
970
971 }
972
973 sub rate_upstream_simple {
974   my( $self, %opt ) = @_;
975
976   $self->set_status_and_rated_price(
977     'rated',
978     sprintf('%.3f', $self->upstream_price),
979     $opt{'svcnum'},
980     'rated_classnum' => $self->calltypenum,
981   );
982 }
983
984 sub rate_single_price {
985   my( $self, %opt ) = @_;
986   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
987
988   # a little false laziness w/abov
989   # $rate_detail = new FS::rate_detail({sec_granularity => ... }) ?
990
991   my $granularity = length($part_pkg->option_cacheable('sec_granularity'))
992                       ? $part_pkg->option_cacheable('sec_granularity')
993                       : 60;
994
995   my $seconds = $part_pkg->option_cacheable('use_duration')
996                   ? $self->duration
997                   : $self->billsec;
998
999   $seconds += $granularity - ( $seconds % $granularity )
1000     if $seconds      # don't granular-ize 0 billsec calls (bills them)
1001     && $granularity  # 0 is per call
1002     && $seconds % $granularity;
1003   my $minutes = $granularity ? ($seconds / 60) : 1;
1004
1005   my $charge_min = $minutes;
1006
1007   ${$opt{single_price_included_min}} -= $minutes;
1008   if ( ${$opt{single_price_included_min}} > 0 ) {
1009     $charge_min = 0;
1010   } else {
1011      $charge_min = 0 - ${$opt{single_price_included_min}};
1012      ${$opt{single_price_included_min}} = 0;
1013   }
1014
1015   my $charge =
1016     sprintf('%.4f', ( $part_pkg->option_cacheable('min_charge') * $charge_min )
1017                     + 0.0000000001 ); #so 1.00005 rounds to 1.0001
1018
1019   $self->set_status_and_rated_price(
1020     'rated',
1021     $charge,
1022     $opt{'svcnum'},
1023     'rated_granularity' => $granularity,
1024     'rated_seconds'     => $seconds,
1025   );
1026
1027 }
1028
1029 =item cdr_termination [ TERMPART ]
1030
1031 =cut
1032
1033 sub cdr_termination {
1034   my $self = shift;
1035
1036   if ( scalar(@_) && $_[0] ) {
1037     my $termpart = shift;
1038
1039     qsearchs('cdr_termination', { acctid   => $self->acctid,
1040                                   termpart => $termpart,
1041                                 }
1042             );
1043
1044   } else {
1045
1046     qsearch('cdr_termination', { acctid => $self->acctid, } );
1047
1048   }
1049
1050 }
1051
1052 =item calldate_unix 
1053
1054 Parses the calldate in SQL string format and returns a UNIX timestamp.
1055
1056 =cut
1057
1058 sub calldate_unix {
1059   str2time(shift->calldate);
1060 }
1061
1062 =item startdate_sql
1063
1064 Parses the startdate in UNIX timestamp format and returns a string in SQL
1065 format.
1066
1067 =cut
1068
1069 sub startdate_sql {
1070   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
1071   $mon++;
1072   $year += 1900;
1073   "$year-$mon-$mday $hour:$min:$sec";
1074 }
1075
1076 =item cdr_carrier
1077
1078 Returns the FS::cdr_carrier object associated with this CDR, or false if no
1079 carrierid is defined.
1080
1081 =cut
1082
1083 my %carrier_cache = ();
1084
1085 sub cdr_carrier {
1086   my $self = shift;
1087   return '' unless $self->carrierid;
1088   $carrier_cache{$self->carrierid} ||=
1089     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
1090 }
1091
1092 =item carriername 
1093
1094 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
1095 no FS::cdr_carrier object is assocated with this CDR.
1096
1097 =cut
1098
1099 sub carriername {
1100   my $self = shift;
1101   my $cdr_carrier = $self->cdr_carrier;
1102   $cdr_carrier ? $cdr_carrier->carriername : '';
1103 }
1104
1105 =item cdr_calltype
1106
1107 Returns the FS::cdr_calltype object associated with this CDR, or false if no
1108 calltypenum is defined.
1109
1110 =cut
1111
1112 my %calltype_cache = ();
1113
1114 sub cdr_calltype {
1115   my $self = shift;
1116   return '' unless $self->calltypenum;
1117   $calltype_cache{$self->calltypenum} ||=
1118     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
1119 }
1120
1121 =item calltypename 
1122
1123 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
1124 no FS::cdr_calltype object is assocated with this CDR.
1125
1126 =cut
1127
1128 sub calltypename {
1129   my $self = shift;
1130   my $cdr_calltype = $self->cdr_calltype;
1131   $cdr_calltype ? $cdr_calltype->calltypename : '';
1132 }
1133
1134 =item downstream_csv [ OPTION => VALUE, ... ]
1135
1136 =cut
1137
1138 my %export_names = (
1139   'simple'  => {
1140     'name'           => 'Simple',
1141     'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
1142   },
1143   'simple2' => {
1144     'name'           => 'Simple with source',
1145     'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
1146                        #"Date,Time,Name,Called From,Destination,Duration,Price",
1147   },
1148   'accountcode_simple' => {
1149     'name'           => 'Simple with accountcode',
1150     'invoice_header' => "Date,Time,Called From,Account,Duration,Price",
1151   },
1152   'basic' => {
1153     'name'           => 'Basic',
1154     'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
1155   },
1156   'default' => {
1157     'name'           => 'Default',
1158     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
1159   },
1160   'source_default' => {
1161     'name'           => 'Default with source',
1162     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
1163   },
1164   'accountcode_default' => {
1165     'name'           => 'Default plus accountcode',
1166     'invoice_header' => 'Date,Time,Account,Number,Destination,Duration,Price',
1167   },
1168   'description_default' => {
1169     'name'           => 'Default with description field as destination',
1170     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
1171   },
1172   'sum_duration' => {
1173     'name'           => 'Summary, one line per service',
1174     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
1175   },
1176   'sum_count' => {
1177     'name'           => 'Number of calls, one line per service',
1178     'invoice_header' => 'Caller,Rate,Messages,Price',
1179   },
1180   'sum_duration_prefix' => {
1181     'name'           => 'Summary, one line per destination prefix',
1182     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
1183   },
1184 );
1185
1186 my %export_formats = ();
1187 sub export_formats {
1188   #my $self = shift;
1189
1190   return %export_formats if keys %export_formats;
1191
1192   my $conf = new FS::Conf;
1193   my $date_format = $conf->config('date_format') || '%m/%d/%Y';
1194
1195   # call duration in the largest units that accurately reflect the  granularity
1196   my $duration_sub = sub {
1197     my($cdr, %opt) = @_;
1198     my $sec = $opt{seconds} || $cdr->billsec;
1199     if ( defined $opt{granularity} && 
1200          $opt{granularity} == 0 ) { #per call
1201       return '1 call';
1202     }
1203     elsif ( defined $opt{granularity} && $opt{granularity} == 60 ) {#full minutes
1204       my $min = int($sec/60);
1205       $min++ if $sec%60;
1206       return $min.'m';
1207     }
1208     else { #anything else
1209       return sprintf("%dm %ds", $sec/60, $sec%60);
1210     }
1211   };
1212
1213   my $price_sub = sub {
1214     my ($cdr, %opt) = @_;
1215     my $price;
1216     if ( defined($opt{charge}) ) {
1217       $price = $opt{charge};
1218     }
1219     elsif ( $opt{inbound} ) {
1220       my $term = $cdr->cdr_termination(1); # 1 = inbound
1221       $price = $term->rated_price if defined $term;
1222     }
1223     else {
1224       $price = $cdr->rated_price;
1225     }
1226     length($price) ? ($opt{money_char} . $price) : '';
1227   };
1228
1229   my $src_sub = sub { $_[0]->clid || $_[0]->src };
1230
1231   %export_formats = (
1232     'simple' => [
1233       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1234       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1235       'userfield',                                     #USER
1236       'dst',                                           #NUMBER_DIALED
1237       $duration_sub,                                   #DURATION
1238       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
1239       $price_sub,
1240     ],
1241     'simple2' => [
1242       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1243       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1244       #'userfield',                                     #USER
1245       $src_sub,                                           #called from
1246       'dst',                                           #NUMBER_DIALED
1247       $duration_sub,                                   #DURATION
1248       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
1249       $price_sub,
1250     ],
1251     'accountcode_simple' => [
1252       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
1253       sub { time2str('%r', shift->calldate_unix ) },   #TIME
1254       $src_sub,                                           #called from
1255       'accountcode',                                   #NUMBER_DIALED
1256       $duration_sub,                                   #DURATION
1257       $price_sub,
1258     ],
1259     'sum_duration' => [ 
1260       # for summary formats, the CDR is a fictitious object containing the 
1261       # total billsec and the phone number of the service
1262       $src_sub,
1263       sub { my($cdr, %opt) = @_; $opt{ratename} },
1264       sub { my($cdr, %opt) = @_; $opt{count} },
1265       sub { my($cdr, %opt) = @_; int($opt{seconds}/60).'m' },
1266       $price_sub,
1267     ],
1268     'sum_count' => [
1269       $src_sub,
1270       sub { my($cdr, %opt) = @_; $opt{ratename} },
1271       sub { my($cdr, %opt) = @_; $opt{count} },
1272       $price_sub,
1273     ],
1274     'basic' => [
1275       sub { time2str('%d %b - %I:%M %p', shift->calldate_unix) },
1276       'dst',
1277       $duration_sub,
1278       $price_sub,
1279     ],
1280     'default' => [
1281
1282       #DATE
1283       sub { time2str($date_format, shift->calldate_unix ) },
1284             # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
1285
1286       #TIME
1287       sub { time2str('%r', shift->calldate_unix ) },
1288             # 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
1289
1290       #DEST ("Number")
1291       sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
1292
1293       #REGIONNAME ("Destination")
1294       sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
1295
1296       #DURATION
1297       $duration_sub,
1298
1299       #PRICE
1300       $price_sub,
1301     ],
1302   );
1303   $export_formats{'source_default'} = [ $src_sub, @{ $export_formats{'default'} }, ];
1304   $export_formats{'accountcode_default'} =
1305     [ @{ $export_formats{'default'} }[0,1],
1306       'accountcode',
1307       @{ $export_formats{'default'} }[2..5],
1308     ];
1309   my @default = @{ $export_formats{'default'} };
1310   $export_formats{'description_default'} = 
1311     [ $src_sub, @default[0..2], 
1312       sub { my($cdr, %opt) = @_; $cdr->description },
1313       @default[4,5] ];
1314
1315   return %export_formats;
1316 }
1317
1318 =item downstream_csv OPTION => VALUE ...
1319
1320 Returns a string of formatted call details for display on an invoice.
1321
1322 Options:
1323
1324 format
1325
1326 charge - override the 'rated_price' field of the CDR
1327
1328 seconds - override the 'billsec' field of the CDR
1329
1330 count - number of usage events included in this record, for summary formats
1331
1332 ratename - name of the rate table used to rate this call
1333
1334 granularity
1335
1336 =cut
1337
1338 sub downstream_csv {
1339   my( $self, %opt ) = @_;
1340
1341   my $format = $opt{'format'};
1342   my %formats = $self->export_formats;
1343   return "Unknown format $format" unless exists $formats{$format};
1344
1345   #my $conf = new FS::Conf;
1346   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
1347   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
1348
1349   my $csv = new Text::CSV_XS;
1350
1351   my @columns =
1352     map {
1353           ref($_) ? &{$_}($self, %opt) : $self->$_();
1354         }
1355     @{ $formats{$format} };
1356
1357   return @columns if defined $opt{'keeparray'};
1358
1359   my $status = $csv->combine(@columns);
1360   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
1361     unless $status;
1362
1363   $csv->string;
1364
1365 }
1366
1367 =back
1368
1369 =head1 CLASS METHODS
1370
1371 =over 4
1372
1373 =item invoice_formats
1374
1375 Returns an ordered list of key value pairs containing invoice format names
1376 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
1377
1378 =cut
1379
1380 # in the future, load this dynamically from detail_format classes
1381
1382 sub invoice_formats {
1383   map { ($_ => $export_names{$_}->{'name'}) }
1384     grep { $export_names{$_}->{'invoice_header'} }
1385     keys %export_names;
1386 }
1387
1388 =item invoice_header FORMAT
1389
1390 Returns a scalar containing the CSV column header for invoice format FORMAT.
1391
1392 =cut
1393
1394 sub invoice_header {
1395   my $format = shift;
1396   $export_names{$format}->{'invoice_header'};
1397 }
1398
1399 =item clear_status 
1400
1401 Clears cdr and any associated cdr_termination statuses - used for 
1402 CDR reprocessing.
1403
1404 =cut
1405
1406 sub clear_status {
1407   my $self = shift;
1408   my %opt = @_;
1409
1410   local $SIG{HUP} = 'IGNORE';
1411   local $SIG{INT} = 'IGNORE';
1412   local $SIG{QUIT} = 'IGNORE';
1413   local $SIG{TERM} = 'IGNORE';
1414   local $SIG{TSTP} = 'IGNORE';
1415   local $SIG{PIPE} = 'IGNORE';
1416
1417   my $oldAutoCommit = $FS::UID::AutoCommit;
1418   local $FS::UID::AutoCommit = 0;
1419   my $dbh = dbh;
1420
1421   if ( $cdr_prerate && $cdr_prerate_cdrtypenums{$self->cdrtypenum}
1422        && $self->rated_ratedetailnum #avoid putting old CDRs back in "rated"
1423        && $self->freesidestatus eq 'done'
1424        && ! $opt{'rerate'}
1425      )
1426   { #special case
1427     $self->freesidestatus('rated');
1428   } else {
1429     $self->freesidestatus('');
1430   }
1431
1432   my $error = $self->replace;
1433   if ( $error ) {
1434     $dbh->rollback if $oldAutoCommit;
1435     return $error;
1436   } 
1437
1438   foreach my $cdr_termination ( $self->cdr_termination ) {
1439       #$cdr_termination->status('');
1440       #$error = $cdr_termination->replace;
1441       $error = $cdr_termination->delete;
1442       if ( $error ) {
1443         $dbh->rollback if $oldAutoCommit;
1444         return $error;
1445       } 
1446   }
1447   
1448   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1449
1450   '';
1451 }
1452
1453 =item import_formats
1454
1455 Returns an ordered list of key value pairs containing import format names
1456 as keys (for use with batch_import) and "pretty" format names as values.
1457
1458 =cut
1459
1460 #false laziness w/part_pkg & part_export
1461
1462 my %cdr_info;
1463 foreach my $INC ( @INC ) {
1464   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
1465   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
1466     warn "attempting to load CDR format info from $file\n" if $DEBUG;
1467     $file =~ /\/(\w+)\.pm$/ or do {
1468       warn "unrecognized file in $INC/FS/cdr/: $file\n";
1469       next;
1470     };
1471     my $mod = $1;
1472     my $info = eval "use FS::cdr::$mod; ".
1473                     "\\%FS::cdr::$mod\::info;";
1474     if ( $@ ) {
1475       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
1476       next;
1477     }
1478     unless ( keys %$info ) {
1479       warn "no %info hash found in FS::cdr::$mod, skipping\n";
1480       next;
1481     }
1482     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
1483     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1484       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
1485       next;
1486     }
1487     $cdr_info{$mod} = $info;
1488   }
1489 }
1490
1491 tie my %import_formats, 'Tie::IxHash',
1492   map  { $_ => $cdr_info{$_}->{'name'} }
1493   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
1494   grep { exists($cdr_info{$_}->{'import_fields'}) }
1495   keys %cdr_info;
1496
1497 sub import_formats {
1498   %import_formats;
1499 }
1500
1501 sub _cdr_min_parser_maker {
1502   my $field = shift;
1503   my @fields = ref($field) ? @$field : ($field);
1504   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
1505   return sub {
1506     my( $cdr, $min ) = @_;
1507     my $sec = eval { _cdr_min_parse($min) };
1508     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
1509     $cdr->$_($sec) foreach @fields;
1510   };
1511 }
1512
1513 sub _cdr_min_parse {
1514   my $min = shift;
1515   sprintf('%.0f', $min * 60 );
1516 }
1517
1518 sub _cdr_date_parser_maker {
1519   my $field = shift;
1520   my %options = @_;
1521   my @fields = ref($field) ? @$field : ($field);
1522   return sub {
1523     my( $cdr, $datestring ) = @_;
1524     my $unixdate = eval { _cdr_date_parse($datestring, %options) };
1525     die "error parsing date for @fields from $datestring: $@\n" if $@;
1526     $cdr->$_($unixdate) foreach @fields;
1527   };
1528 }
1529
1530 sub _cdr_date_parse {
1531   my $date = shift;
1532   my %options = @_;
1533
1534   return '' unless length($date); #that's okay, it becomes NULL
1535   return '' if $date eq 'NA'; #sansay
1536
1537   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 ) {
1538     my $time = str2time($date);
1539     return $time if $time > 100000; #just in case
1540   }
1541
1542   my($year, $mon, $day, $hour, $min, $sec);
1543
1544   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
1545   #taqua  #2007-10-31 08:57:24.113000000
1546
1547   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|$)/ ) {
1548     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1549   } 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|$)/ ) {
1550     # 8/26/2010 12:20:01
1551     # optionally without seconds
1552     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1553     $sec = 0 if !defined($sec);
1554   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d+\.\d+)(\D|$)/ ) {
1555     # broadsoft: 20081223201938.314
1556     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1557   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\d+(\D|$)/ ) {
1558     # Taqua OM:  20050422203450943
1559     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1560   } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ ) {
1561     # WIP: 20100329121420
1562     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1563   } elsif ( $date =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/) {
1564     # Telos
1565     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
1566     $options{gmt} = 1;
1567   } else {
1568      die "unparsable date: $date"; #maybe we shouldn't die...
1569   }
1570
1571   return '' if ( $year == 1900 || $year == 1970 ) && $mon == 1 && $day == 1
1572             && $hour == 0 && $min == 0 && $sec == 0;
1573
1574   if ($options{gmt}) {
1575     timegm($sec, $min, $hour, $day, $mon-1, $year);
1576   } else {
1577     timelocal($sec, $min, $hour, $day, $mon-1, $year);
1578   }
1579 }
1580
1581 =item batch_import HASHREF
1582
1583 Imports CDR records.  Available options are:
1584
1585 =over 4
1586
1587 =item file
1588
1589 Filename
1590
1591 =item format
1592
1593 =item params
1594
1595 Hash reference of preset fields, typically cdrbatch
1596
1597 =item empty_ok
1598
1599 Set true to prevent throwing an error on empty imports
1600
1601 =back
1602
1603 =cut
1604
1605 my %import_options = (
1606   'table'         => 'cdr',
1607
1608   'batch_keycol'  => 'cdrbatchnum',
1609   'batch_table'   => 'cdr_batch',
1610   'batch_namecol' => 'cdrbatch',
1611
1612   'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
1613                      keys %cdr_info
1614                },
1615
1616                           #drop the || 'csv' to allow auto xls for csv types?
1617   'format_types' => { map { $_ => lc($cdr_info{$_}->{'type'} || 'csv'); }
1618                           keys %cdr_info
1619                     },
1620
1621   'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
1622                             keys %cdr_info
1623                       },
1624
1625   'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
1626                               keys %cdr_info
1627                         },
1628
1629   'format_fixedlength_formats' =>
1630     { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
1631           keys %cdr_info
1632     },
1633
1634   'format_xml_formats' =>
1635     { map { $_ => $cdr_info{$_}->{'xml_format'}; }
1636           keys %cdr_info
1637     },
1638
1639   'format_asn_formats' =>
1640     { map { $_ => $cdr_info{$_}->{'asn_format'}; }
1641           keys %cdr_info
1642     },
1643
1644   'format_row_callbacks' => { map { $_ => $cdr_info{$_}->{'row_callback'}; }
1645                                   keys %cdr_info
1646                             },
1647 );
1648
1649 sub _import_options {
1650   \%import_options;
1651 }
1652
1653 sub batch_import {
1654   my $opt = shift;
1655
1656   my $iopt = _import_options;
1657   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
1658
1659   if ( defined $opt->{'cdrtypenum'} ) {
1660         $opt->{'preinsert_callback'} = sub {
1661                 my($record,$param) = (shift,shift);
1662                 $record->cdrtypenum($opt->{'cdrtypenum'});
1663                 '';
1664         };
1665   }
1666
1667   FS::Record::batch_import( $opt );
1668
1669 }
1670
1671 =item process_batch_import
1672
1673 =cut
1674
1675 sub process_batch_import {
1676   my $job = shift;
1677
1678   my $opt = _import_options;
1679 #  $opt->{'params'} = [ 'format', 'cdrbatch' ];
1680
1681   FS::Record::process_batch_import( $job, $opt, @_ );
1682
1683 }
1684 #  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
1685 #    @columns = map { s/^ +//; $_; } @columns;
1686 #  }
1687
1688 # _ upgrade_data
1689 #
1690 # Used by FS::Upgrade to migrate to a new database.
1691
1692 sub _upgrade_data {
1693   my ($class, %opts) = @_;
1694
1695   warn "$me upgrading $class\n" if $DEBUG;
1696
1697   my $sth = dbh->prepare(
1698     'SELECT DISTINCT(cdrbatch) FROM cdr WHERE cdrbatch IS NOT NULL'
1699   ) or die dbh->errstr;
1700
1701   $sth->execute or die $sth->errstr;
1702
1703   my %cdrbatchnum = ();
1704   while (my $row = $sth->fetchrow_arrayref) {
1705
1706     my $cdr_batch = qsearchs( 'cdr_batch', { 'cdrbatch' => $row->[0] } );
1707     unless ( $cdr_batch ) {
1708       $cdr_batch = new FS::cdr_batch { 'cdrbatch' => $row->[0] };
1709       my $error = $cdr_batch->insert;
1710       die $error if $error;
1711     }
1712
1713     $cdrbatchnum{$row->[0]} = $cdr_batch->cdrbatchnum;
1714   }
1715
1716   $sth = dbh->prepare('UPDATE cdr SET cdrbatch = NULL, cdrbatchnum = ? WHERE cdrbatch IS NOT NULL AND cdrbatch = ?') or die dbh->errstr;
1717
1718   foreach my $cdrbatch (keys %cdrbatchnum) {
1719     $sth->execute($cdrbatchnum{$cdrbatch}, $cdrbatch) or die $sth->errstr;
1720   }
1721
1722 }
1723
1724 =item ip_addr_sql FIELD RANGE
1725
1726 Returns an SQL condition to search for CDRs with an IP address 
1727 within RANGE.  FIELD is either 'src_ip_addr' or 'dst_ip_addr'.  RANGE 
1728 should be in the form "a.b.c.d-e.f.g.h' (dotted quads), where any of 
1729 the leftmost octets of the second address can be omitted if they're 
1730 the same as the first address.
1731
1732 =cut
1733
1734 sub ip_addr_sql {
1735   my $class = shift;
1736   my ($field, $range) = @_;
1737   $range =~ /^[\d\.-]+$/ or die "bad ip address range '$range'";
1738   my @r = split('-', $range);
1739   my @saddr = split('\.', $r[0] || '');
1740   my @eaddr = split('\.', $r[1] || '');
1741   unshift @eaddr, (undef) x (4 - scalar @eaddr);
1742   for(0..3) {
1743     $eaddr[$_] = $saddr[$_] if !defined $eaddr[$_];
1744   }
1745   "$field >= '".sprintf('%03d.%03d.%03d.%03d', @saddr) . "' AND ".
1746   "$field <= '".sprintf('%03d.%03d.%03d.%03d', @eaddr) . "'";
1747 }
1748
1749 =back
1750
1751 =head1 BUGS
1752
1753 =head1 SEE ALSO
1754
1755 L<FS::Record>, schema.html from the base documentation.
1756
1757 =cut
1758
1759 1;
1760