changes to support new invoice template features, #28080
[freeside.git] / FS / FS / cdr.pm
index 3a6b01b..4126d5f 100644 (file)
@@ -11,6 +11,7 @@ use Date::Parse;
 use Date::Format;
 use Time::Local;
 use List::Util qw( first min );
 use Date::Format;
 use Time::Local;
 use List::Util qw( first min );
+use Text::CSV_XS;
 use FS::UID qw( dbh );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
 use FS::UID qw( dbh );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
@@ -91,6 +92,8 @@ following fields are currently supported:
 
 =item dst_ip_addr - Destination IP address (same)
 
 
 =item dst_ip_addr - Destination IP address (same)
 
+=item dst_term - Terminating destination number (if different from dst)
+
 =item startdate - Start of call (UNIX-style integer timestamp)
 
 =item answerdate - Answer time of call (UNIX-style integer timestamp)
 =item startdate - Start of call (UNIX-style integer timestamp)
 
 =item answerdate - Answer time of call (UNIX-style integer timestamp)
@@ -193,6 +196,7 @@ sub table_info {
         #'lastdata'              => '',
         'src_ip_addr'           => 'Source IP',
         'dst_ip_addr'           => 'Dest. IP',
         #'lastdata'              => '',
         'src_ip_addr'           => 'Source IP',
         'dst_ip_addr'           => 'Dest. IP',
+        'dst_term'              => 'Termination dest.',
         'startdate'             => 'Start date',
         'answerdate'            => 'Answer date',
         'enddate'               => 'End date',
         'startdate'             => 'Start date',
         'answerdate'            => 'Answer date',
         'enddate'               => 'End date',
@@ -325,12 +329,16 @@ sub check {
     $self->billsec(  $self->enddate - $self->answerdate );
   } 
 
     $self->billsec(  $self->enddate - $self->answerdate );
   } 
 
+  if ( ! $self->enddate && $self->startdate && $self->duration ) {
+    $self->enddate( $self->startdate + $self->duration );
+  }
+
   $self->set_charged_party;
 
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
   $self->set_charged_party;
 
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
-       $self->ut_numbern('acctid')
+       $self->ut_numbern('acctid');
 
   #add a config option to turn these back on if someone needs 'em
   #
 
   #add a config option to turn these back on if someone needs 'em
   #
@@ -342,7 +350,7 @@ sub check {
   #
   #  # Telstra =1, Optus = 2, RSL COM = 3
   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
   #
   #  # 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;
@@ -421,12 +429,25 @@ sub set_charged_party {
 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.
 
+If status is being changed from 'rated' to some other status, also removes
+any usage allocations to this CDR.
+
 =cut
 
 sub set_status {
   my($self, $status) = @_;
 =cut
 
 sub set_status {
   my($self, $status) = @_;
+  my $old_status = $self->freesidestatus;
   $self->freesidestatus($status);
   $self->freesidestatus($status);
-  $self->replace;
+  my $error = $self->replace;
+  if ( $old_status eq 'rated' and $status ne 'done' ) {
+    # deallocate any usage
+    foreach (qsearch('cdr_cust_pkg_usage', {acctid => $self->acctid})) {
+      my $cust_pkg_usage = $_->cust_pkg_usage;
+      $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $_->minutes);
+      $error ||= $cust_pkg_usage->replace || $_->delete;
+    }
+  }
+  $error;
 }
 
 =item set_status_and_rated_price STATUS RATED_PRICE [ SVCNUM [ OPTION => VALUE ... ] ]
 }
 
 =item set_status_and_rated_price STATUS RATED_PRICE [ SVCNUM [ OPTION => VALUE ... ] ]
@@ -478,29 +499,97 @@ sub set_status_and_rated_price {
   }
 }
 
   }
 }
 
+=item parse_number [ OPTION => VALUE ... ]
+
+Returns two scalars, the countrycode and the rest of the number.
+
+Options are passed as name-value pairs.  Currently available options are:
+
+=over 4
+
+=item column
+
+The column containing the number to be parsed.  Defaults to dst.
+
+=item international_prefix
+
+The digits for international dialing.  Defaults to '011'  The value '+' is
+always recognized.
+
+=item domestic_prefix
+
+The digits for domestic long distance dialing.  Defaults to '1'
+
+=back
+
+=cut
+
+sub parse_number {
+  my ($self, %options) = @_;
+
+  my $field = $options{column} || 'dst';
+  my $intl = $options{international_prefix} || '011';
+  my $countrycode = '';
+  my $number = $self->$field();
+
+  my $to_or_from = 'concerning';
+  $to_or_from = 'from' if $field eq 'src';
+  $to_or_from = 'to' if $field eq 'dst';
+  warn "parsing call $to_or_from $number\n" if $DEBUG;
+
+  #remove non-phone# stuff and whitespace
+  $number =~ s/\s//g;
+#          my $proto = '';
+#          $dest =~ s/^(\w+):// and $proto = $1; #sip:
+#          my $siphost = '';
+#          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
+
+  if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
+       || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
+     )
+  {
+
+    my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
+    #first look for 1 digit country code
+    if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
+      $countrycode = $one;
+      $number = $u1.$u2.$rest;
+    } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
+      $countrycode = $two;
+      $number = $u2.$rest;
+    } else { #3 digit country code
+      $countrycode = $three;
+      $number = $rest;
+    }
+
+  } else {
+    my $domestic_prefix =
+      exists($options{domestic_prefix}) ? $options{domestic_prefix} : '';
+    $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
+    $number =~ s/^$countrycode//;# if length($number) > 10;
+  }
+
+  return($countrycode, $number);
+
+}
+
 =item rate [ OPTION => VALUE ... ]
 
 Rates this CDR according and sets the status to 'rated'.
 
 =item rate [ OPTION => VALUE ... ]
 
 Rates this CDR according and sets the status to 'rated'.
 
-Available options are: part_pkg, svcnum, single_price_included_minutes, region_group, region_group_included_minutes.
+Available options are: part_pkg, svcnum, plan_included_min,
+detail_included_min_hashref.
 
 part_pkg is required.
 
 If svcnum is specified, will also associate this CDR with the specified svcnum.
 
 
 part_pkg is required.
 
 If svcnum is specified, will also associate this CDR with the specified svcnum.
 
-single_price_included_minutes is requried for single_price price plans
-(otherwise unused/ignored).  It should be set to a scalar reference of the
-number of included minutes and will be decremented by the rated minutes of this
+plan_included_min should be set to a scalar reference of the number of 
+included minutes and will be decremented by the rated minutes of this
 CDR.
 
 CDR.
 
-region_group_included_minutes is required for prefix price plans which have
-included minutes (otherwise unused/ignored).  It should be set to a scalar
-reference of the number of included minutes and will be decremented by the
-rated minutes of this CDR.
-
-region_group_included_minutes_hashref is required for prefix price plans which
-have included minues (otehrwise unused/ignored).  It should be set to an empty
-hashref at the start of a month's rating and then preserved across CDRs.
+detail_included_min_hashref should be set to an empty hashref at the 
+start of a month's rating and then preserved across CDRs.
 
 =cut
 
 
 =cut
 
@@ -524,6 +613,7 @@ our %interval_cache = (); # for timed rates
 sub rate_prefix {
   my( $self, %opt ) = @_;
   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
 sub rate_prefix {
   my( $self, %opt ) = @_;
   my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
+  my $cust_pkg = $opt{'cust_pkg'};
 
   my $da_rewrote = 0;
   # this will result in those CDRs being marked as done... is that 
 
   my $da_rewrote = 0;
   # this will result in those CDRs being marked as done... is that 
@@ -551,67 +641,72 @@ sub rate_prefix {
                                             );
   }
 
                                             );
   }
 
+  if ( $part_pkg->option_cacheable('skip_same_customer')
+      and ! $self->is_tollfree ) {
+    my ($dst_countrycode, $dst_number) = $self->parse_number(
+      column => 'dst',
+      international_prefix => $part_pkg->option_cacheable('international_prefix'),
+      domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+    );
+    my $dst_same_cust = FS::Record->scalar_sql(
+        'SELECT COUNT(svc_phone.svcnum) AS count '.
+        'FROM cust_pkg ' .
+        'JOIN cust_svc   USING (pkgnum) ' .
+        'JOIN svc_phone  USING (svcnum) ' .
+        'WHERE svc_phone.countrycode = ' . dbh->quote($dst_countrycode) .
+        ' AND svc_phone.phonenum = ' . dbh->quote($dst_number) .
+        ' AND cust_pkg.custnum = ' . $cust_pkg->custnum,
+    );
+    if ( $dst_same_cust > 0 ) {
+      warn "not charging for CDR (same source and destination customer)\n" if $DEBUG;
+      return $self->set_status_and_rated_price( 'skipped',
+                                                0,
+                                                $opt{'svcnum'},
+                                              );
+    }
+  }
+
     
     
+
+
   ###
   # look up rate details based on called station id
   # (or calling station id for toll free calls)
   ###
 
   ###
   # look up rate details based on called station id
   # (or calling station id for toll free calls)
   ###
 
-  my( $to_or_from, $number );
-  if ( $self->is_tollfree && ! $part_pkg->option_cacheable('disable_tollfree') )
+  my $eff_ratenum = $self->is_tollfree('accountcode')
+    ? $part_pkg->option_cacheable('accountcode_tollfree_ratenum')
+    : '';
+
+  my( $to_or_from, $column );
+  if(
+        ( $self->is_tollfree
+           && ! $part_pkg->option_cacheable('disable_tollfree')
+        )
+     or ( $eff_ratenum
+           && $part_pkg->option_cacheable('accountcode_tollfree_field') eq 'src'
+        )
+    )
   { #tollfree call
     $to_or_from = 'from';
   { #tollfree call
     $to_or_from = 'from';
-    $number = $self->src;
+    $column = 'src';
   } else { #regular call
     $to_or_from = 'to';
   } else { #regular call
     $to_or_from = 'to';
-    $number = $self->dst;
+    $column = 'dst';
   }
 
   }
 
-  warn "parsing call $to_or_from $number\n" if $DEBUG;
-
-  #remove non-phone# stuff and whitespace
-  $number =~ s/\s//g;
-#          my $proto = '';
-#          $dest =~ s/^(\w+):// and $proto = $1; #sip:
-#          my $siphost = '';
-#          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
-
   #determine the country code
   #determine the country code
-  my $intl = $part_pkg->option_cacheable('international_prefix') || '011';
-  my $countrycode = '';
-  if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
-       || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
-     )
-  {
-
-    my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
-    #first look for 1 digit country code
-    if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
-      $countrycode = $one;
-      $number = $u1.$u2.$rest;
-    } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
-      $countrycode = $two;
-      $number = $u2.$rest;
-    } else { #3 digit country code
-      $countrycode = $three;
-      $number = $rest;
-    }
-
-  } else {
-    my $domestic_prefix = $part_pkg->option_cacheable('domestic_prefix');
-    $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
-    $number =~ s/^$countrycode//;# if length($number) > 10;
-  }
+  my ($countrycode, $number) = $self->parse_number(
+    column => $column,
+    international_prefix => $part_pkg->option_cacheable('international_prefix'),
+    domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+  );
 
   warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG;
   my $pretty_dst = "+$countrycode $number";
   #asterisks here causes inserting the detail to barf, so:
   $pretty_dst =~ s/\*//g;
 
 
   warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG;
   my $pretty_dst = "+$countrycode $number";
   #asterisks here causes inserting the detail to barf, so:
   $pretty_dst =~ s/\*//g;
 
-  my $eff_ratenum = $self->is_tollfree('accountcode')
-    ? $part_pkg->option_cacheable('accountcode_tollfree_ratenum')
-    : '';
-
   my $ratename = '';
   my $intrastate_ratenum = $part_pkg->option_cacheable('intrastate_ratenum');
   if ( $intrastate_ratenum && !$self->is_tollfree ) {
   my $ratename = '';
   my $intrastate_ratenum = $part_pkg->option_cacheable('intrastate_ratenum');
   if ( $intrastate_ratenum && !$self->is_tollfree ) {
@@ -622,12 +717,20 @@ sub rate_prefix {
     # -disregard private or unknown numbers
     # -there is exactly one record in rate_prefix for a given NPANXX
     # -default to interstate if we can't find one or both of the prefixes
     # -disregard private or unknown numbers
     # -there is exactly one record in rate_prefix for a given NPANXX
     # -default to interstate if we can't find one or both of the prefixes
-    my $dstprefix = $self->dst;
+    my (undef, $dstprefix) = $self->parse_number(
+      column => 'dst',
+      international_prefix => $part_pkg->option_cacheable('international_prefix'),
+      domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+    );
     $dstprefix =~ /^(\d{6})/;
     $dstprefix = qsearchs('rate_prefix', {   'countrycode' => '1', 
                                                 'npa' => $1, 
                                          }) || '';
     $dstprefix =~ /^(\d{6})/;
     $dstprefix = qsearchs('rate_prefix', {   'countrycode' => '1', 
                                                 'npa' => $1, 
                                          }) || '';
-    my $srcprefix = $self->src;
+    my (undef, $srcprefix) = $self->parse_number(
+      column => 'src',
+      international_prefix => $part_pkg->option_cacheable('international_prefix'),
+      domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+    );
     $srcprefix =~ /^(\d{6})/;
     $srcprefix = qsearchs('rate_prefix', {   'countrycode' => '1',
                                              'npa' => $1, 
     $srcprefix =~ /^(\d{6})/;
     $srcprefix = qsearchs('rate_prefix', {   'countrycode' => '1',
                                              'npa' => $1, 
@@ -720,11 +823,16 @@ sub rate_prefix {
   my $seconds_left = $part_pkg->option_cacheable('use_duration')
                        ? $self->duration
                        : $self->billsec;
   my $seconds_left = $part_pkg->option_cacheable('use_duration')
                        ? $self->duration
                        : $self->billsec;
-  # charge for the first (conn_sec) seconds
-  my $seconds = min($seconds_left, $rate_detail->conn_sec);
-  $seconds_left -= $seconds; 
-  $weektime     += $seconds;
-  my $charge = $rate_detail->conn_charge; 
+
+  #no, do this later so it respects (group) included minutes
+  #  # charge for the first (conn_sec) seconds
+  #  my $seconds = min($seconds_left, $rate_detail->conn_sec);
+  #  $seconds_left -= $seconds; 
+  #  $weektime     += $seconds;
+  #  my $charge = $rate_detail->conn_charge; 
+  my $seconds = 0;
+  my $charge = 0;
+  my $connection_charged = 0;
 
   my $etime;
   while($seconds_left) {
 
   my $etime;
   while($seconds_left) {
@@ -765,11 +873,6 @@ sub rate_prefix {
 
     $seconds_left -= $charge_sec;
 
 
     $seconds_left -= $charge_sec;
 
-    my $included_min = $opt{'region_group_included_min_hashref'} || {};
-
-    $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
-      unless exists $included_min->{$regionnum}{$ratetimenum};
-
     my $granularity = $rate_detail->sec_granularity;
 
     my $minutes;
     my $granularity = $rate_detail->sec_granularity;
 
     my $minutes;
@@ -787,30 +890,70 @@ sub rate_prefix {
 
     $seconds += $charge_sec;
 
 
     $seconds += $charge_sec;
 
-    my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0;
+    if ( $rate_detail->min_included ) {
+      # the old, kind of deprecated way to do this:
+      # 
+      # The rate detail itself has included minutes.  We MUST have a place
+      # to track them.
+      my $included_min = $opt{'detail_included_min_hashref'}
+        or return "unable to rate CDR: rate detail has included minutes, but ".
+                  "no detail_included_min_hashref provided.\n";
+
+      # by default, set the included minutes for this region/time to
+      # what's in the rate_detail
+      $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
+        unless exists $included_min->{$regionnum}{$ratetimenum};
+
+      if ( $included_min->{$regionnum}{$ratetimenum} >= $minutes ) {
+        $charge_sec = 0;
+        $included_min->{$regionnum}{$ratetimenum} -= $minutes;
+      } else {
+        $charge_sec -= ($included_min->{$regionnum}{$ratetimenum} * 60);
+        $included_min->{$regionnum}{$ratetimenum} = 0;
+      }
+    } elsif ( $opt{plan_included_min} && ${ $opt{plan_included_min} } > 0 ) {
+      # The package definition has included minutes, but only for in-group
+      # rate details.  Decrement them if this is an in-group call.
+      if ( $rate_detail->region_group ) {
+        if ( ${ $opt{'plan_included_min'} } >= $minutes ) {
+          $charge_sec = 0;
+          ${ $opt{'plan_included_min'} } -= $minutes;
+        } else {
+          $charge_sec -= (${ $opt{'plan_included_min'} } * 60);
+          ${ $opt{'plan_included_min'} } = 0;
+        }
+      }
+    } else {
+      # the new way!
+      my $applied_min = $cust_pkg->apply_usage(
+        'cdr'         => $self,
+        'rate_detail' => $rate_detail,
+        'minutes'     => $minutes
+      );
+      # for now, usage pools deal only in whole minutes
+      $charge_sec -= $applied_min * 60;
+    }
 
 
-    ${$opt{region_group_included_min}} -= $minutes 
-        if $region_group && $rate_detail->region_group;
+    if ( $charge_sec > 0 ) {
+
+      #NOW do connection charges here... right?
+      #my $conn_seconds = min($seconds_left, $rate_detail->conn_sec);
+      my $conn_seconds = 0;
+      unless ( $connection_charged++ ) { #only one connection charge
+        $conn_seconds = min($charge_sec, $rate_detail->conn_sec);
+        $seconds_left -= $conn_seconds; 
+        $weektime     += $conn_seconds;
+        $charge += $rate_detail->conn_charge; 
+      }
 
 
-    $included_min->{$regionnum}{$ratetimenum} -= $minutes;
-    if (
-         $included_min->{$regionnum}{$ratetimenum} <= 0
-         && ( ${$opt{region_group_included_min}} <= 0
-              || ! $rate_detail->region_group
-            )
-       )
-    {
                            #should preserve (display?) this
                            #should preserve (display?) this
-      my $charge_min = 0 - $included_min->{$regionnum}{$ratetimenum};
-      $included_min->{$regionnum}{$ratetimenum} = 0;
-      $charge += ($rate_detail->min_charge * $charge_min); #still not rounded
-
-    } elsif ( ${$opt{region_group_included_min}} > 0
-              && $region_group
-              && $rate_detail->region_group 
-           )
-    {
-        $included_min->{$regionnum}{$ratetimenum} = 0 
+      if ( $granularity == 0 ) { # per call rate
+        $charge += $rate_detail->min_charge;
+      } else {
+        my $charge_min = ( $charge_sec - $conn_seconds ) / 60;
+        $charge += ($rate_detail->min_charge * $charge_min) if $charge_min > 0; #still not rounded
+      }
+
     }
 
     # choose next rate_detail
     }
 
     # choose next rate_detail
@@ -827,9 +970,15 @@ sub rate_prefix {
   # this is why we need regionnum/rate_region....
   warn "  (rate region $rate_region)\n" if $DEBUG;
 
   # this is why we need regionnum/rate_region....
   warn "  (rate region $rate_region)\n" if $DEBUG;
 
+  # NOW round it.
+  my $rounding = $part_pkg->option_cacheable('rounding') || 2;
+  my $sprintformat = '%.'. $rounding. 'f';
+  my $roundup = 10**(-3-$rounding);
+  my $price = sprintf($sprintformat, $charge + $roundup);
+
   $self->set_status_and_rated_price(
     'rated',
   $self->set_status_and_rated_price(
     'rated',
-    sprintf('%.2f', $charge + 0.000001), # NOW round it.
+    $price,
     $opt{'svcnum'},
     'rated_pretty_dst'    => $pretty_dst,
     'rated_regionname'    => $rate_region->regionname,
     $opt{'svcnum'},
     'rated_pretty_dst'    => $pretty_dst,
     'rated_regionname'    => $rate_region->regionname,
@@ -850,6 +999,8 @@ sub rate_upstream_simple {
     sprintf('%.3f', $self->upstream_price),
     $opt{'svcnum'},
     'rated_classnum' => $self->calltypenum,
     sprintf('%.3f', $self->upstream_price),
     $opt{'svcnum'},
     'rated_classnum' => $self->calltypenum,
+    'rated_seconds'  => $self->billsec,
+    # others? upstream_*_regionname => rated_regionname is possible
   );
 }
 
   );
 }
 
@@ -876,12 +1027,12 @@ sub rate_single_price {
 
   my $charge_min = $minutes;
 
 
   my $charge_min = $minutes;
 
-  ${$opt{single_price_included_min}} -= $minutes;
-  if ( ${$opt{single_price_included_min}} > 0 ) {
+  ${$opt{plan_included_min}} -= $minutes;
+  if ( ${$opt{plan_included_min}} > 0 ) {
     $charge_min = 0;
   } else {
     $charge_min = 0;
   } else {
-     $charge_min = 0 - ${$opt{single_price_included_min}};
-     ${$opt{single_price_included_min}} = 0;
+     $charge_min = 0 - ${$opt{plan_included_min}};
+     ${$opt{plan_included_min}} = 0;
   }
 
   my $charge =
   }
 
   my $charge =
@@ -1007,6 +1158,8 @@ sub calltypename {
 
 =cut
 
 
 =cut
 
+# in the future, load this dynamically from detail_format classes
+
 my %export_names = (
   'simple'  => {
     'name'           => 'Simple',
 my %export_names = (
   'simple'  => {
     'name'           => 'Simple',
@@ -1025,6 +1178,10 @@ my %export_names = (
     'name'           => 'Basic',
     'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
   },
     'name'           => 'Basic',
     'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
   },
+  'basic_upstream_dst_regionname' => {
+    'name'           => 'Basic with upstream destination name',
+    'invoice_header' => "Date/Time,Called Number,Destination,Min/Sec,Price",
+  },
   'default' => {
     'name'           => 'Default',
     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
   'default' => {
     'name'           => 'Default',
     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
@@ -1053,6 +1210,10 @@ my %export_names = (
     'name'           => 'Summary, one line per destination prefix',
     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
   },
     'name'           => 'Summary, one line per destination prefix',
     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
   },
+  'sum_count_class' => {
+    'name'           => 'Summary, one line per usage class',
+    'invoice_header' => 'Caller,Class,Calls,Price',
+  },
 );
 
 my %export_formats = ();
 );
 
 my %export_formats = ();
@@ -1098,6 +1259,8 @@ sub export_formats {
     length($price) ? ($opt{money_char} . $price) : '';
   };
 
     length($price) ? ($opt{money_char} . $price) : '';
   };
 
+  my $src_sub = sub { $_[0]->clid || $_[0]->src };
+
   %export_formats = (
     'simple' => [
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
   %export_formats = (
     'simple' => [
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
@@ -1112,7 +1275,7 @@ sub export_formats {
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
       sub { time2str('%r', shift->calldate_unix ) },   #TIME
       #'userfield',                                     #USER
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
       sub { time2str('%r', shift->calldate_unix ) },   #TIME
       #'userfield',                                     #USER
-      'src',                                           #called from
+      $src_sub,                                           #called from
       'dst',                                           #NUMBER_DIALED
       $duration_sub,                                   #DURATION
       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
       'dst',                                           #NUMBER_DIALED
       $duration_sub,                                   #DURATION
       #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
@@ -1121,7 +1284,7 @@ sub export_formats {
     'accountcode_simple' => [
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
       sub { time2str('%r', shift->calldate_unix ) },   #TIME
     'accountcode_simple' => [
       sub { time2str($date_format, shift->calldate_unix ) },   #DATE
       sub { time2str('%r', shift->calldate_unix ) },   #TIME
-      'src',                                           #called from
+      $src_sub,                                           #called from
       'accountcode',                                   #NUMBER_DIALED
       $duration_sub,                                   #DURATION
       $price_sub,
       'accountcode',                                   #NUMBER_DIALED
       $duration_sub,                                   #DURATION
       $price_sub,
@@ -1129,14 +1292,14 @@ sub export_formats {
     'sum_duration' => [ 
       # for summary formats, the CDR is a fictitious object containing the 
       # total billsec and the phone number of the service
     'sum_duration' => [ 
       # for summary formats, the CDR is a fictitious object containing the 
       # total billsec and the phone number of the service
-      'src',
+      $src_sub,
       sub { my($cdr, %opt) = @_; $opt{ratename} },
       sub { my($cdr, %opt) = @_; $opt{count} },
       sub { my($cdr, %opt) = @_; int($opt{seconds}/60).'m' },
       $price_sub,
     ],
     'sum_count' => [
       sub { my($cdr, %opt) = @_; $opt{ratename} },
       sub { my($cdr, %opt) = @_; $opt{count} },
       sub { my($cdr, %opt) = @_; int($opt{seconds}/60).'m' },
       $price_sub,
     ],
     'sum_count' => [
-      'src',
+      $src_sub,
       sub { my($cdr, %opt) = @_; $opt{ratename} },
       sub { my($cdr, %opt) = @_; $opt{count} },
       $price_sub,
       sub { my($cdr, %opt) = @_; $opt{ratename} },
       sub { my($cdr, %opt) = @_; $opt{count} },
       $price_sub,
@@ -1170,7 +1333,7 @@ sub export_formats {
       $price_sub,
     ],
   );
       $price_sub,
     ],
   );
-  $export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
+  $export_formats{'source_default'} = [ $src_sub, @{ $export_formats{'default'} }, ];
   $export_formats{'accountcode_default'} =
     [ @{ $export_formats{'default'} }[0,1],
       'accountcode',
   $export_formats{'accountcode_default'} =
     [ @{ $export_formats{'default'} }[0,1],
       'accountcode',
@@ -1178,7 +1341,7 @@ sub export_formats {
     ];
   my @default = @{ $export_formats{'default'} };
   $export_formats{'description_default'} = 
     ];
   my @default = @{ $export_formats{'default'} };
   $export_formats{'description_default'} = 
-    [ 'src', @default[0..2], 
+    [ $src_sub, @default[0..2], 
       sub { my($cdr, %opt) = @_; $cdr->description },
       @default[4,5] ];
 
       sub { my($cdr, %opt) = @_; $cdr->description },
       @default[4,5] ];
 
@@ -1216,8 +1379,6 @@ sub downstream_csv {
   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
 
   #$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 =
   my $csv = new Text::CSV_XS;
 
   my @columns =
@@ -1508,9 +1669,20 @@ my %import_options = (
           keys %cdr_info
     },
 
           keys %cdr_info
     },
 
-  'format_row_callbacks' => { map { $_ => $cdr_info{$_}->{'row_callback'}; }
-                                  keys %cdr_info
-                            },
+  'format_asn_formats' =>
+    { map { $_ => $cdr_info{$_}->{'asn_format'}; }
+          keys %cdr_info
+    },
+
+  'format_row_callbacks' =>
+    { map { $_ => $cdr_info{$_}->{'row_callback'}; }
+          keys %cdr_info
+    },
+
+  'format_parser_opts' =>
+    { map { $_ => $cdr_info{$_}->{'parser_opt'}; }
+          keys %cdr_info
+    },
 );
 
 sub _import_options {
 );
 
 sub _import_options {