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