fix problems using inventory for UID (and other fields controlled by check in svc_acc...
[freeside.git] / FS / FS / cdr.pm
index 5078ff6..d9c602f 100644 (file)
@@ -1,18 +1,23 @@
 package FS::cdr;
 
 use strict;
 package FS::cdr;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( @ISA @EXPORT_OK $DEBUG );
+use Exporter;
+use Tie::IxHash;
 use Date::Parse;
 use Date::Format;
 use Time::Local;
 use FS::UID qw( dbh );
 use Date::Parse;
 use Date::Format;
 use Time::Local;
 use FS::UID qw( dbh );
+use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
 use FS::cdr_type;
 use FS::cdr_calltype;
 use FS::cdr_carrier;
 use FS::Record qw( qsearch qsearchs );
 use FS::cdr_type;
 use FS::cdr_calltype;
 use FS::cdr_carrier;
-use FS::cdr_upstream_rate;
 
 @ISA = qw(FS::Record);
 
 @ISA = qw(FS::Record);
+@EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
+
+$DEBUG = 0;
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -124,6 +129,10 @@ following fields are currently supported:
 
 =item freesidestatus - NULL, done (or something)
 
 
 =item freesidestatus - NULL, done (or something)
 
+=item freesiderewritestatus - NULL, done (or something)
+
+=item cdrbatch
+
 =back
 
 =head1 METHODS
 =back
 
 =head1 METHODS
@@ -143,6 +152,54 @@ points to.  You can ask the object for a copy with the I<hash> method.
 
 sub table { 'cdr'; }
 
 
 sub table { 'cdr'; }
 
+sub table_info {
+  {
+    'fields' => {
+#XXX fill in some (more) nice names
+        #'acctid'                => '',
+        'calldate'              => 'Call date',
+        'clid'                  => 'Caller ID',
+        'src'                   => 'Source',
+        'dst'                   => 'Destination',
+        'dcontext'              => 'Dest. context',
+        'channel'               => 'Channel',
+        'dstchannel'            => 'Destination channel',
+        #'lastapp'               => '',
+        #'lastdata'              => '',
+        'startdate'             => 'Start date',
+        'answerdate'            => 'Answer date',
+        'enddate'               => 'End date',
+        'duration'              => 'Duration',
+        'billsec'               => 'Billable seconds',
+        'disposition'           => 'Disposition',
+        'amaflags'              => 'AMA flags',
+        'accountcode'           => 'Account code',
+        #'uniqueid'              => '',
+        'userfield'             => 'User field',
+        #'cdrtypenum'            => '',
+        'charged_party'         => 'Charged party',
+        #'upstream_currency'     => '',
+        'upstream_price'        => 'Upstream price',
+        #'upstream_rateplanid'   => '',
+        #'ratedetailnum'         => '',
+        'rated_price'           => 'Rated price',
+        #'distance'              => '',
+        #'islocal'               => '',
+        #'calltypenum'           => '',
+        #'description'           => '',
+        #'quantity'              => '',
+        'carrierid'             => 'Carrier ID',
+        #'upstream_rateid'       => '',
+        'svcnum'                => 'Freeside service',
+        'freesidestatus'        => 'Freeside status',
+        'freesiderewritestatus' => 'Freeside rewrite status',
+        'cdrbatch'              => 'Batch',
+    },
+
+  };
+
+}
+
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
@@ -220,40 +277,105 @@ sub check {
 #    || $self->ut_numbern('upstream_rateid')
 #    || $self->ut_numbern('svcnum')
 #    || $self->ut_textn('freesidestatus')
 #    || $self->ut_numbern('upstream_rateid')
 #    || $self->ut_numbern('svcnum')
 #    || $self->ut_textn('freesidestatus')
+#    || $self->ut_textn('freesiderewritestatus')
 #  ;
 #  return $error if $error;
 
   $self->calldate( $self->startdate_sql )
     if !$self->calldate && $self->startdate;
 
 #  ;
 #  return $error if $error;
 
   $self->calldate( $self->startdate_sql )
     if !$self->calldate && $self->startdate;
 
-  unless ( $self->charged_party ) {
-    if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
-      $self->charged_party($self->dst);
-    } else {
-      $self->charged_party($self->src);
-    }
+  #was just for $format eq 'taqua' but can't see the harm... add something to
+  #disable if it becomes a problem
+  if ( $self->duration eq '' && $self->enddate && $self->startdate ) {
+    $self->duration( $self->enddate - $self->startdate  );
   }
   }
+  if ( $self->billsec eq '' && $self->enddate && $self->answerdate ) {
+    $self->billsec(  $self->enddate - $self->answerdate );
+  } 
+
+  $self->set_charged_party;
 
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
        $self->ut_numbern('acctid')
 
 
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
        $self->ut_numbern('acctid')
 
-    #Usage = 1, S&E = 7, OC&C = 8
-    || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
-
-    #the big list in appendix 2
-    || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
-
-    # Telstra =1, Optus = 2, RSL COM = 3
-    || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
+  #add a config option to turn these back on if someone needs 'em
+  #
+  #  #Usage = 1, S&E = 7, OC&C = 8
+  #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
+  #
+  #  #the big list in appendix 2
+  #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
+  #
+  #  # Telstra =1, Optus = 2, RSL COM = 3
+  #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
   ;
   return $error if $error;
 
   $self->SUPER::check;
 }
 
   ;
   return $error if $error;
 
   $self->SUPER::check;
 }
 
-=item set_status_and_rated_price STATUS [ RATED_PRICE ]
+=item is_tollfree
+
+  Returns true when the cdr represents a toll free number and false otherwise.
+
+=cut
+
+sub is_tollfree {
+  my $self = shift;
+  ( $self->dst =~ /^(\+?1)?8(8|([02-7])\3)/ ) ? 1 : 0;
+}
+
+=item set_charged_party
+
+If the charged_party field is already set, does nothing.  Otherwise:
+
+If the cdr-charged_party-accountcode config option is enabled, sets the
+charged_party to the accountcode.
+
+Otherwise sets the charged_party normally: to the src field in most cases,
+or to the dst field if it is a toll free number.
+
+=cut
+
+sub set_charged_party {
+  my $self = shift;
+
+  my $conf = new FS::Conf;
+
+  unless ( $self->charged_party ) {
+
+    if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
+
+      my $charged_party = $self->accountcode;
+      $charged_party =~ s/^0+//
+        if $conf->exists('cdr-charged_party-accountcode-trim_leading_0s');
+      $self->charged_party( $charged_party );
+
+    } else {
+
+      if ( $self->is_tollfree ) {
+        $self->charged_party($self->dst);
+      } else {
+        $self->charged_party($self->src);
+      }
+
+    }
+
+  }
+
+#  my $prefix = $conf->config('cdr-charged_party-truncate_prefix');
+#  my $prefix_len = length($prefix);
+#  my $trunc_len = $conf->config('cdr-charged_party-truncate_length');
+#
+#  $self->charged_party( substr($self->charged_party, 0, $trunc_len) )
+#    if $prefix_len && $trunc_len
+#    && substr($self->charged_party, 0, $prefix_len) eq $prefix;
+
+}
+
+=item set_status_and_rated_price STATUS [ RATED_PRICE [ SVCNUM ] ]
 
 Sets the status to the provided string.  If there is an error, returns the
 error, otherwise returns false.
 
 Sets the status to the provided string.  If there is an error, returns the
 error, otherwise returns false.
@@ -261,9 +383,10 @@ error, otherwise returns false.
 =cut
 
 sub set_status_and_rated_price {
 =cut
 
 sub set_status_and_rated_price {
-  my($self, $status, $rated_price) = @_;
+  my($self, $status, $rated_price, $svcnum) = @_;
   $self->freesidestatus($status);
   $self->rated_price($rated_price);
   $self->freesidestatus($status);
   $self->rated_price($rated_price);
+  $self->svcnum($svcnum) if $svcnum;
   $self->replace();
 }
 
   $self->replace();
 }
 
@@ -349,80 +472,111 @@ sub calltypename {
   $cdr_calltype ? $cdr_calltype->calltypename : '';
 }
 
   $cdr_calltype ? $cdr_calltype->calltypename : '';
 }
 
-=item cdr_upstream_rate
-
-Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
-string if no FS::cdr_upstream_rate object is associated with this CDR.
+=item downstream_csv [ OPTION => VALUE, ... ]
 
 =cut
 
 
 =cut
 
-sub cdr_upstream_rate {
-  my $self = shift;
-  return '' unless $self->upstream_rateid;
-  qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
-    or '';
-}
+my %export_names = (
+  'simple'  => {
+    'name'           => 'Simple',
+    'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
+  },
+  'simple2' => {
+    'name'           => 'Simple with source',
+    'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
+                       #"Date,Time,Name,Called From,Destination,Duration,Price",
+  },
+  'default' => {
+    'name'           => 'Default',
+    'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
+  },
+  'source_default' => {
+    'name'           => 'Default with source',
+    'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
+  },
+  'accountcode_default' => {
+    'name'           => 'Default plus accountcode',
+    'invoice_header' => 'Date,Time,Account,Number,Destination,Duration,Price',
+  },
+);
 
 
-=item _convergent_format COLUMN [ COUNTRYCODE ]
+my $duration_sub = sub {
+  my($cdr, %opt) = @_;
+  if ( $opt{minutes} ) {
+    $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
+  } else {
+    sprintf('%.2fm', $cdr->billsec / 60 );
+  }
+};
 
 
-Returns the number in COLUMN formatted as follows:
+my %export_formats = (
+  'simple' => [
+    sub { time2str('%D', shift->calldate_unix ) },   #DATE
+    sub { time2str('%r', shift->calldate_unix ) },   #TIME
+    'userfield',                                     #USER
+    'dst',                                           #NUMBER_DIALED
+    $duration_sub,                                   #DURATION
+    #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+    sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
+  ],
+  'simple2' => [
+    sub { time2str('%D', shift->calldate_unix ) },   #DATE
+    sub { time2str('%r', shift->calldate_unix ) },   #TIME
+    #'userfield',                                     #USER
+    'src',                                           #called from
+    'dst',                                           #NUMBER_DIALED
+    $duration_sub,                                   #DURATION
+    #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+    sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
+  ],
+  'default' => [
 
 
-If the country code does not match COUNTRYCODE (default "61"), it is returned
-unchanged.
+    #DATE
+    sub { time2str('%D', shift->calldate_unix ) },
+          # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
 
 
-If the country code does match COUNTRYCODE (default "61"), it is removed.  In
-addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
+    #TIME
+    sub { time2str('%r', shift->calldate_unix ) },
+          # 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
 
 
-=cut
+    #DEST ("Number")
+    sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
 
 
-sub _convergent_format {
-  my( $self, $field ) = ( shift, shift );
-  my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
-  #my $number = $self->$field();
-  my $number = $self->get($field);
-  #if ( $number =~ s/^(\+|011)$countrycode// ) {
-  if ( $number =~ s/^\+$countrycode// ) {
-    $number = "0$number"
-      unless $number =~ /^1[389]/; #???
-  }
-  $number;
-}
+    #REGIONNAME ("Destination")
+    sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
 
 
-=item downstream_csv [ OPTION => VALUE, ... ]
+    #DURATION
+    $duration_sub,
 
 
-=cut
+    #PRICE
+    sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; },
 
 
-my %export_formats = (
-  'convergent' => [
-    'carriername', #CARRIER
-    sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
-    sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
-    sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
-    sub { time2str('%T',       shift->calldate_unix ) }, #TIME
-    'billsec', #'duration', #DURATION
-    sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
-    '', #XXX add (from prefixes in most recent email) #FROM_DESC
-    '', #XXX add (from prefixes in most recent email) #TO_DESC
-    'calltypename', #CLASS_CODE
-    'rated_price', #PRICE
-    sub { shift->rated_price ? 'Y' : 'N' }, #RATED
-    '', #OTHER_INFO
   ],
 );
   ],
 );
+$export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
+$export_formats{'accountcode_default'} =
+  [ @{ $export_formats{'default'} }[0,1],
+    'accountcode',
+    @{ $export_formats{'default'} }[2..5],
+  ];
 
 sub downstream_csv {
   my( $self, %opt ) = @_;
 
 
 sub downstream_csv {
   my( $self, %opt ) = @_;
 
-  my $format = $opt{'format'}; # 'convergent';
+  my $format = $opt{'format'};
   return "Unknown format $format" unless exists $export_formats{$format};
 
   return "Unknown format $format" unless exists $export_formats{$format};
 
+  #my $conf = new FS::Conf;
+  #$opt{'money_char'} ||= $conf->config('money_char') || '$';
+  $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
+
   eval "use Text::CSV_XS;";
   die $@ if $@;
   my $csv = new Text::CSV_XS;
 
   my @columns =
     map {
   eval "use Text::CSV_XS;";
   die $@ if $@;
   my $csv = new Text::CSV_XS;
 
   my @columns =
     map {
-          ref($_) ? &{$_}($self) : $self->$_();
+          ref($_) ? &{$_}($self, %opt) : $self->$_();
         }
     @{ $export_formats{$format} };
 
         }
     @{ $export_formats{$format} };
 
@@ -440,223 +594,225 @@ sub downstream_csv {
 
 =over 4
 
 
 =over 4
 
-=item batch_import
+=item invoice_formats
+
+Returns an ordered list of key value pairs containing invoice format names
+as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
+
+=cut
+
+sub invoice_formats {
+  map { ($_ => $export_names{$_}->{'name'}) }
+    grep { $export_names{$_}->{'invoice_header'} }
+    keys %export_names;
+}
+
+=item invoice_header FORMAT
+
+Returns a scalar containing the CSV column header for invoice format FORMAT.
 
 =cut
 
 
 =cut
 
-my($tmp_mday, $tmp_mon, $tmp_year);
+sub invoice_header {
+  my $format = shift;
+  $export_names{$format}->{'invoice_header'};
+}
+
+=item import_formats
+
+Returns an ordered list of key value pairs containing import format names
+as keys (for use with batch_import) and "pretty" format names as values.
+
+=cut
+
+#false laziness w/part_pkg & part_export
+
+my %cdr_info;
+foreach my $INC ( @INC ) {
+  warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
+    warn "attempting to load CDR format info from $file\n" if $DEBUG;
+    $file =~ /\/(\w+)\.pm$/ or do {
+      warn "unrecognized file in $INC/FS/cdr/: $file\n";
+      next;
+    };
+    my $mod = $1;
+    my $info = eval "use FS::cdr::$mod; ".
+                    "\\%FS::cdr::$mod\::info;";
+    if ( $@ ) {
+      die "error using FS::cdr::$mod (skipping): $@\n" if $@;
+      next;
+    }
+    unless ( keys %$info ) {
+      warn "no %info hash found in FS::cdr::$mod, skipping\n";
+      next;
+    }
+    warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
+    if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
+      warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
+      next;
+    }
+    $cdr_info{$mod} = $info;
+  }
+}
+
+tie my %import_formats, 'Tie::IxHash',
+  map  { $_ => $cdr_info{$_}->{'name'} }
+  sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
+  grep { exists($cdr_info{$_}->{'import_fields'}) }
+  keys %cdr_info;
+
+sub import_formats {
+  %import_formats;
+}
+
+sub _cdr_min_parser_maker {
+  my $field = shift;
+  my @fields = ref($field) ? @$field : ($field);
+  @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
+  return sub {
+    my( $cdr, $min ) = @_;
+    my $sec = eval { _cdr_min_parse($min) };
+    die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
+    $cdr->$_($sec) foreach @fields;
+  };
+}
+
+sub _cdr_min_parse {
+  my $min = shift;
+  sprintf('%.0f', $min * 60 );
+}
 
 sub _cdr_date_parser_maker {
   my $field = shift;
 
 sub _cdr_date_parser_maker {
   my $field = shift;
+  my %options = @_;
+  my @fields = ref($field) ? @$field : ($field);
   return sub {
   return sub {
-    my( $cdr, $date ) = @_;
-    $cdr->$field( _cdr_date_parse($date) );
+    my( $cdr, $datestring ) = @_;
+    my $unixdate = eval { _cdr_date_parse($datestring, %options) };
+    die "error parsing date for @fields from $datestring: $@\n" if $@;
+    $cdr->$_($unixdate) foreach @fields;
   };
 }
 
 sub _cdr_date_parse {
   my $date = shift;
   };
 }
 
 sub _cdr_date_parse {
   my $date = shift;
+  my %options = @_;
 
   return '' unless length($date); #that's okay, it becomes NULL
 
   return '' unless length($date); #that's okay, it becomes NULL
+  return '' if $date eq 'NA'; #sansay
 
 
-  #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
-  $date =~ /^\s*(\d{4})\D(\d{1,2})\D(\d{1,2})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})\s*$/
-    or die "unparsable date: $date"; #maybe we shouldn't die...
-  my($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  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 ) {
+    my $time = str2time($date);
+    return $time if $time > 100000; #just in case
+  }
 
 
-  timelocal($sec, $min, $hour, $day, $mon-1, $year);
-}
+  my($year, $mon, $day, $hour, $min, $sec);
 
 
-#http://www.the-asterisk-book.com/unstable/funktionen-cdr.html
-my %amaflags = (
-  DEFAULT       => 0,
-  OMIT          => 1, #asterisk 1.4+
-  IGNORE        => 1, #asterisk 1.2
-  BILLING       => 2, #asterisk 1.4+
-  BILL          => 2, #asterisk 1.2
-  DOCUMENTATION => 3,
-  #? '' => 0,
-);
+  #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
+  #taqua  #2007-10-31 08:57:24.113000000
+
+  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|$)/ ) {
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } 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|$)/ ) {
+    ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d+\.\d+)(\D|$)/ ) {
+    # broadsoft: 20081223201938.314
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6);
+  } else {
+     die "unparsable date: $date"; #maybe we shouldn't die...
+  }
 
 
-my %import_formats = (
-  'asterisk' => [
-    'accountcode',
-    'src',
-    'dst',
-    'dcontext',
-    'clid',
-    'channel',
-    'dstchannel',
-    'lastapp',
-    'lastdata',
-    _cdr_date_parser_maker('startdate'),
-    _cdr_date_parser_maker('answerdate'),
-    _cdr_date_parser_maker('enddate'),
-    'duration',
-    'billsec',
-    'disposition',
-    sub { my($cdr, $amaflags) = @_; $cdr->amaflags($amaflags{$amaflags}); },
-    'uniqueid',
-    'userfield',
-  ],
-  'unitel' => [
-    'uniqueid',
-    #'cdr_type',
-    'cdrtypenum',
-    'calldate', # may need massaging?  huh maybe not...
-    #'billsec', #XXX duration and billsec?
-                sub { $_[0]->billsec(  $_[1] );
-                      $_[0]->duration( $_[1] );
-                    },
-    'src',
-    'dst', # XXX needs to have "+61" prepended unless /^\+/ ???
-    'charged_party',
-    'upstream_currency',
-    'upstream_price',
-    'upstream_rateplanid',
-    'distance',
-    'islocal',
-    'calltypenum',
-    'startdate',  #XXX needs massaging
-    'enddate',    #XXX same
-    'description',
-    'quantity',
-    'carrierid',
-    'upstream_rateid',
-  ],
-  'simple' => [
+  return '' if ( $year == 1900 || $year == 1970 ) && $mon == 1 && $day == 1
+            && $hour == 0 && $min == 0 && $sec == 0;
 
 
-    # Date
-    sub { my($cdr, $date) = @_;
-          $date =~ /^(\d{1,2})\/(\d{1,2})\/(\d\d(\d\d)?)$/
-            or die "unparsable date: $date"; #maybe we shouldn't die...
-          #$cdr->startdate( timelocal(0, 0, 0 ,$2, $1-1, $3) );
-          ($tmp_mday, $tmp_mon, $tmp_year) = ( $2, $1-1, $3 );
-        },
-
-    # Time
-    sub { my($cdr, $time) = @_;
-          #my($sec, $min, $hour, $mday, $mon, $year)= localtime($cdr->startdate);
-          $time =~ /^(\d{1,2}):(\d{1,2}):(\d{1,2})$/
-            or die "unparsable time: $time"; #maybe we shouldn't die...
-          #$cdr->startdate( timelocal($3, $2, $1 ,$mday, $mon, $year) );
-          $cdr->startdate(
-            timelocal($3, $2, $1 ,$tmp_mday, $tmp_mon, $tmp_year)
-          );
-        },
-
-    # Source_Number
-    'src',
-
-    # Terminating_Number
-    'dst',
-
-    # Duration
-    sub { my($cdr, $min) = @_;
-          my $sec = sprintf('%.0f', $min * 60 );
-          $cdr->billsec(  $sec );
-          $cdr->duration( $sec );
-        },
+  if ($options{gmt}) {
+    timegm($sec, $min, $hour, $day, $mon-1, $year);
+  } else {
+    timelocal($sec, $min, $hour, $day, $mon-1, $year);
+  }
+}
 
 
-  ],
-);
+=item batch_import HASHREF
 
 
-sub batch_import {
-  my $param = shift;
+Imports CDR records.  Available options are:
 
 
-  my $fh = $param->{filehandle};
-  my $format = $param->{format};
+=over 4
 
 
-  return "Unknown format $format" unless exists $import_formats{$format};
+=item file
 
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
+Filename
 
 
-  my $csv = new Text::CSV_XS;
+=item format
 
 
-  my $imported = 0;
-  #my $columns;
+=item params
 
 
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+Hash reference of preset fields, typically cdrbatch
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+=item empty_ok
 
 
-  if ( $format eq 'simple' ) { # and other formats with a header too?
+Set true to prevent throwing an error on empty imports
 
 
-  }
+=back
 
 
-  my $body = 0;
-  my $line;
-  while ( defined($line=<$fh>) ) {
+=cut
 
 
-    #skip header...
-    if ( ! $body++ && $format eq 'simple' && $line =~ /^[\w\, ]+$/ ) {
-      next;
-    }
+my %import_options = (
+  'table'   => 'cdr',
 
 
-    $csv->parse($line) or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't parse: ". $csv->error_input();
-    };
+  'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
+                     keys %cdr_info
+               },
 
 
-    my @columns = $csv->fields();
-    #warn join('-',@columns);
+                          #drop the || 'csv' to allow auto xls for csv types?
+  'format_types' => { map { $_ => ( lc($cdr_info{$_}->{'type'}) || 'csv' ); }
+                          keys %cdr_info
+                    },
 
 
-    if ( $format eq 'simple' ) {
-      @columns = map { s/^ +//; $_; } @columns;
-    }
+  'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
+                            keys %cdr_info
+                      },
 
 
-    my @later = ();
-    my %cdr =
-      map {
+  'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
+                              keys %cdr_info
+                        },
 
 
-        my $field_or_sub = $_;
-        if ( ref($field_or_sub) ) {
-          push @later, $field_or_sub, shift(@columns);
-          ();
-        } else {
-          ( $field_or_sub => shift @columns );
-        }
+  'format_fixedlength_formats' =>
+    { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
+          keys %cdr_info
+    },
+);
 
 
-      }
-      @{ $import_formats{$format} }
-    ;
+sub _import_options {
+  \%import_options;
+}
 
 
-    my $cdr = new FS::cdr ( \%cdr );
+sub batch_import {
+  my $opt = shift;
 
 
-    while ( scalar(@later) ) {
-      my $sub = shift @later;
-      my $data = shift @later;
-      &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
-    }
+  my $iopt = _import_options;
+  $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
 
 
-    my $error = $cdr->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+  FS::Record::batch_import( $opt );
 
 
-      #or just skip?
-      #next;
-    }
+}
 
 
-    $imported++;
-  }
+=item process_batch_import
+
+=cut
 
 
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+sub process_batch_import {
+  my $job = shift;
 
 
-  #might want to disable this if we skip records for any reason...
-  return "Empty file!" unless $imported;
+  my $opt = _import_options;
+  $opt->{'params'} = [ 'format', 'cdrbatch' ];
 
 
-  '';
+  FS::Record::process_batch_import( $job, $opt, @_ );
 
 }
 
 }
+#  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
+#    @columns = map { s/^ +//; $_; } @columns;
+#  }
 
 =back
 
 
 =back