add LEC billing
[freeside.git] / FS / FS / cust_main.pm
index 8a0124a..4a5cff2 100644 (file)
@@ -27,6 +27,7 @@ use FS::part_pkg;
 use FS::part_bill_event;
 use FS::cust_bill_event;
 use FS::cust_tax_exempt;
+use FS::type_pkgs;
 use FS::Msgcat qw(gettext);
 
 @ISA = qw( FS::Record );
@@ -157,7 +158,7 @@ FS::Record.  The following fields are currently supported:
 
 =item ship_fax - phone (optional)
 
-=item payby - `CARD' (credit cards), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to BILL)
+=item payby - `CARD' (credit cards), `CHEK' (electronic check), `LECB' (Phone bill billing), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to BILL)
 
 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
 
@@ -171,6 +172,8 @@ FS::Record.  The following fields are currently supported:
 
 =item comments - comments (optional)
 
+=item referral_custnum - referring customer number
+
 =back
 
 =head1 METHODS
@@ -220,7 +223,8 @@ invoicing_list destination to the newly-created svc_acct.  Here's an example:
 
 sub insert {
   my $self = shift;
-  my @param = @_;
+  my $cust_pkgs = @_ ? shift : {};
+  my $invoicing_list = @_ ? shift : '';
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -261,27 +265,35 @@ sub insert {
     return $error;
   }
 
-  if ( @param ) { # CUST_PKG_HASHREF
-    my $cust_pkgs = shift @param;
-    foreach my $cust_pkg ( keys %$cust_pkgs ) {
-      $cust_pkg->custnum( $self->custnum );
-      $error = $cust_pkg->insert;
+  # invoicing list
+  if ( $invoicing_list ) {
+    $error = $self->check_invoicing_list( $invoicing_list );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "checking invoicing_list (transaction rolled back): $error";
+    }
+    $self->invoicing_list( $invoicing_list );
+  }
+
+  # packages
+  foreach my $cust_pkg ( keys %$cust_pkgs ) {
+    $cust_pkg->custnum( $self->custnum );
+    $error = $cust_pkg->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "inserting cust_pkg (transaction rolled back): $error";
+    }
+    foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
+      $svc_something->pkgnum( $cust_pkg->pkgnum );
+      if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
+        $svc_something->seconds( $svc_something->seconds + $seconds );
+        $seconds = 0;
+      }
+      $error = $svc_something->insert;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
-        return "inserting cust_pkg (transaction rolled back): $error";
-      }
-      foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
-        $svc_something->pkgnum( $cust_pkg->pkgnum );
-        if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
-          $svc_something->seconds( $svc_something->seconds + $seconds );
-          $seconds = 0;
-        }
-        $error = $svc_something->insert;
-        if ( $error ) {
-          $dbh->rollback if $oldAutoCommit;
-          #return "inserting svc_ (transaction rolled back): $error";
-          return $error;
-        }
+        #return "inserting svc_ (transaction rolled back): $error";
+        return $error;
       }
     }
   }
@@ -291,16 +303,6 @@ sub insert {
     return "No svc_acct record to apply pre-paid time";
   }
 
-  if ( @param ) { # INVOICING_LIST_ARYREF
-    my $invoicing_list = shift @param;
-    $error = $self->check_invoicing_list( $invoicing_list );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "checking invoicing_list (transaction rolled back): $error";
-    }
-    $self->invoicing_list( $invoicing_list );
-  }
-
   if ( $amount ) {
     my $cust_credit = new FS::cust_credit {
       'custnum' => $self->custnum,
@@ -482,17 +484,22 @@ sub replace {
     $self->invoicing_list( $invoicing_list );
   }
 
-  if ( $self->payby eq 'CARD' &&
+  if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
        grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
-    # card info has changed, want to retry realtime_card invoice events
+    # card/check info has changed, want to retry realtime_card invoice events
+    #false laziness w/collect
     foreach my $cust_bill_event (
       grep {
              #$_->part_bill_event->plan eq 'realtime-card'
-             $_->part_bill_event->eventcode eq '$cust_bill->realtime_card();'
+             $_->part_bill_event->eventcode =~
+                 /^\$cust_bill\->realtime_(card|ach|lec)\(\);$/
                && $_->status eq 'done'
                && $_->statustext
            }
-        $self->open_cust_bill->cust_bill_event
+        map { $_->cust_bill_event }
+          grep { $_->cust_bill_event }
+            $self->open_cust_bill
+
     ) {
       my $error = $cust_bill_event->retry;
       if ( $error ) {
@@ -500,6 +507,7 @@ sub replace {
         return "error scheduling invoice events for retry: $error";
       }
     }
+    #eslaf
 
   }
 
@@ -659,7 +667,7 @@ sub check {
     }
   }
 
-  $self->payby =~ /^(CARD|BILL|COMP|PREPAY)$/
+  $self->payby =~ /^(CARD|CHEK|BILL|COMP|PREPAY)$/
     or return "Illegal payby: ". $self->payby;
   $self->payby($1);
 
@@ -676,6 +684,22 @@ sub check {
     return gettext('unknown_card_type')
       if cardtype($self->payinfo) eq "Unknown";
 
+  } elsif ( $self->payby eq 'CHEK' ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/[\D\@]//g;
+    $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
+    $payinfo = "$1\@$2";
+    $self->payinfo($payinfo);
+
+  } elsif ( $self->payby eq 'LECB' ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/\D//g;
+    $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
+    $payinfo = $1;
+    $self->payinfo($payinfo);
+
   } elsif ( $self->payby eq 'BILL' ) {
 
     $error = $self->ut_textn('payinfo');
@@ -700,7 +724,7 @@ sub check {
 
   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
     return "Expriation date required"
-      unless $self->payby eq 'BILL' || $self->payby eq 'PREPAY';
+      unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
     $self->paydate('');
   } else {
     $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
@@ -708,10 +732,11 @@ sub check {
     my $y = length($2) == 4 ? $2 : "20$2";
     $self->paydate("$y-$1-01");
     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
-    return gettext('expired_card') if $y<$nowy || ( $y==$nowy && $1<$nowm );
+    return gettext('expired_card')
+      if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
   }
 
-  if ( $self->payname eq '' &&
+  if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
        ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
     $self->payname( $self->first. " ". $self->getfield('last') );
   } else {
@@ -898,10 +923,12 @@ sub bill {
   my( $total_setup, $total_recur ) = ( 0, 0 );
   #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
   my @cust_bill_pkg = ();
-  my $tax = 0;##
+  #my $tax = 0;##
   #my $taxable_charged = 0;##
   #my $charged = 0;##
 
+  my %tax;
+
   foreach my $cust_pkg (
     qsearch('cust_pkg', { 'custnum' => $self->custnum } )
   ) {
@@ -1048,8 +1075,8 @@ sub bill {
               $dbh->rollback if $oldAutoCommit;
               return
                 "fatal: can't find tax rate for state/county/country/taxclass ".
-                join('/', map $self->$_(), qw(state county country taxclass) ).
-                "\n";
+                join('/', ( map $self->$_(), qw(state county country) ),
+                          $part_pkg->taxclass ).  "\n";
             };
 
           if ( $cust_main_county->exempt_amount ) {
@@ -1095,7 +1122,10 @@ sub bill {
           } #if $cust_main_county->exempt_amount
 
           $taxable_charged = sprintf( "%.2f", $taxable_charged);
-          $tax += $taxable_charged * $cust_main_county->tax / 100
+
+          #$tax += $taxable_charged * $cust_main_county->tax / 100
+          $tax{ $cust_main_county->taxname || 'Tax' } +=
+            $taxable_charged * $cust_main_county->tax / 100
 
         } #unless $self->tax =~ /Y/i
           #       || $self->payby eq 'COMP'
@@ -1128,16 +1158,17 @@ sub bill {
 #      $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
 #    );
 
-  $tax = sprintf("%.2f", $tax);
-  if ( $tax > 0 ) {
+  foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
+    my $tax = sprintf("%.2f", $tax{$taxname} );
     $charged = sprintf( "%.2f", $charged+$tax );
 
     my $cust_bill_pkg = new FS::cust_bill_pkg ({
-      'pkgnum' => 0,
-      'setup'  => $tax,
-      'recur'  => 0,
-      'sdate'  => '',
-      'edate'  => '',
+      'pkgnum'   => 0,
+      'setup'    => $tax,
+      'recur'    => 0,
+      'sdate'    => '',
+      'edate'    => '',
+      'itemdesc' => $taxname,
     });
     push @cust_bill_pkg, $cust_bill_pkg;
   }
@@ -1192,6 +1223,8 @@ invoice_time - Use this time when deciding when to print invoices and
 late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse>
 for conversion functions.
 
+retry_card - Retry cards even when not scheduled by invoice events.
+
 batch_card - This option is deprecated.  See the invoice events web interface
 to control whether cards are batched or run against a realtime gateway.
 
@@ -1224,6 +1257,28 @@ sub collect {
     return '';
   }
 
+  if ( exists($options{'retry_card'}) && $options{'retry_card'} ) {
+    #false laziness w/replace
+    foreach my $cust_bill_event (
+      grep {
+             #$_->part_bill_event->plan eq 'realtime-card'
+             $_->part_bill_event->eventcode eq '$cust_bill->realtime_card();'
+               && $_->status eq 'done'
+               && $_->statustext
+           }
+        map { $_->cust_bill_event }
+          grep { $_->cust_bill_event }
+            $self->open_cust_bill
+    ) {
+      my $error = $cust_bill_event->retry;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "error scheduling invoice events for retry: $error";
+      }
+    }
+    #eslaf
+  }
+
   foreach my $cust_bill ( $self->cust_bill ) {
 
     #this has to be before next's
@@ -1242,6 +1297,7 @@ sub collect {
 
     next unless $amount > 0;
 
+
     foreach my $part_bill_event (
       sort {    $a->seconds   <=> $b->seconds
              || $a->weight    <=> $b->weight
@@ -1553,7 +1609,6 @@ sub invoicing_list {
     }
     my %seen = map { $_->address => 1 } @cust_main_invoice;
     foreach my $address ( @{$arrayref} ) {
-      #unless ( grep { $address eq $_->address } @cust_main_invoice ) {
       next if exists $seen{$address} && $seen{$address};
       $seen{$address} = 1;
       my $cust_main_invoice = new FS::cust_main_invoice ( {
@@ -1595,24 +1650,36 @@ sub check_invoicing_list {
   '';
 }
 
-=item default_invoicing_list
+=item set_default_invoicing_list
 
-Sets the invoicing list to all accounts associated with this customer.
+Sets the invoicing list to all accounts associated with this customer,
+overwriting any previous invoicing list.
 
 =cut
 
-sub default_invoicing_list {
+sub set_default_invoicing_list {
   my $self = shift;
-  my @list = ();
+  $self->invoicing_list($self->all_emails);
+}
+
+=item all_emails
+
+Returns the email addresses of all accounts provisioned for this customer.
+
+=cut
+
+sub all_emails {
+  my $self = shift;
+  my %list;
   foreach my $cust_pkg ( $self->all_pkgs ) {
     my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
     my @svc_acct =
       map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
         grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
           @cust_svc;
-    push @list, map { $_->email } @svc_acct;
+    $list{$_}=1 foreach map { $_->email } @svc_acct;
   }
-  $self->invoicing_list(\@list);
+  keys %list;
 }
 
 =item invoicing_list_addpost
@@ -1704,7 +1771,7 @@ sub credit {
   $cust_credit->insert;
 }
 
-=item charge AMOUNT PKG COMMENT
+=item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
 
 Creates a one-time charge for this customer.  If there is an error, returns
 the error, otherwise returns false.
@@ -1712,18 +1779,62 @@ the error, otherwise returns false.
 =cut
 
 sub charge {
-  my ( $self, $amount, $pkg, $comment ) = @_;
+  my ( $self, $amount ) = ( shift, shift );
+  my $pkg      = @_ ? shift : 'One-time charge';
+  my $comment  = @_ ? shift : '$'. sprintf("%.2f",$amount);
+  my $taxclass = @_ ? shift : '';
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
   my $part_pkg = new FS::part_pkg ( {
-    'pkg'      => $pkg || 'One-time charge',
-    'comment'  => $comment || '$'. sprintf("%.2f".$amount),
+    'pkg'      => $pkg,
+    'comment'  => $comment,
     'setup'    => $amount,
     'freq'     => 0,
     'recur'    => '0',
     'disabled' => 'Y',
+    'taxclass' => $taxclass,
   } );
 
-  $part_pkg->insert;
+  my $error = $part_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  my $pkgpart = $part_pkg->pkgpart;
+  my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
+  unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
+    my $type_pkgs = new FS::type_pkgs \%type_pkgs;
+    $error = $type_pkgs->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  my $cust_pkg = new FS::cust_pkg ( {
+    'custnum' => $self->custnum,
+    'pkgpart' => $pkgpart,
+  } );
+
+  $error = $cust_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 
 }
 
@@ -1735,7 +1846,8 @@ Returns all the invoices (see L<FS::cust_bill>) for this customer.
 
 sub cust_bill {
   my $self = shift;
-  qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+  sort { $a->_date <=> $b->_date }
+    qsearch('cust_bill', { 'custnum' => $self->custnum, } )
 }
 
 =item open_cust_bill
@@ -1889,6 +2001,201 @@ sub append_fuzzyfiles {
   1;
 }
 
+=item batch_import
+
+=cut
+
+sub batch_import {
+  my $param = shift;
+  #warn join('-',keys %$param);
+  my $fh = $param->{filehandle};
+  my $agentnum = $param->{agentnum};
+  my $refnum = $param->{refnum};
+  my $pkgpart = $param->{pkgpart};
+  my @fields = @{$param->{fields}};
+
+  eval "use Date::Parse;";
+  die $@ if $@;
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+  #warn $csv;
+  #warn $fh;
+
+  my $imported = 0;
+  #my $columns;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  
+  #while ( $columns = $csv->getline($fh) ) {
+  my $line;
+  while ( defined($line=<$fh>) ) {
+
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+    #warn join('-',@columns);
+
+    my %cust_main = (
+      agentnum => $agentnum,
+      refnum   => $refnum,
+      country  => 'US', #default
+      payby    => 'BILL', #default
+      paydate  => '12/2037', #default
+    );
+    my $billtime = time;
+    my %cust_pkg = ( pkgpart => $pkgpart );
+    foreach my $field ( @fields ) {
+      if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
+        #$cust_pkg{$1} = str2time( shift @$columns );
+        if ( $1 eq 'setup' ) {
+          $billtime = str2time(shift @columns);
+        } else {
+          $cust_pkg{$1} = str2time( shift @columns );
+        }
+      } else {
+        #$cust_main{$field} = shift @$columns; 
+        $cust_main{$field} = shift @columns; 
+      }
+    }
+
+    my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
+    my $cust_main = new FS::cust_main ( \%cust_main );
+    use Tie::RefHash;
+    tie my %hash, 'Tie::RefHash'; #this part is important
+    $hash{$cust_pkg} = [] if $pkgpart;
+    my $error = $cust_main->insert( \%hash );
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't insert customer for $line: $error";
+    }
+
+    #false laziness w/bill.cgi
+    $error = $cust_main->bill( 'time' => $billtime );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't bill customer for $line: $error";
+    }
+
+    $cust_main->apply_payments;
+    $cust_main->apply_credits;
+
+    $error = $cust_main->collect();
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't collect customer for $line: $error";
+    }
+
+    $imported++;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+=item batch_charge
+
+=cut
+
+sub batch_charge {
+  my $param = shift;
+  #warn join('-',keys %$param);
+  my $fh = $param->{filehandle};
+  my @fields = @{$param->{fields}};
+
+  eval "use Date::Parse;";
+  die $@ if $@;
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+  #warn $csv;
+  #warn $fh;
+
+  my $imported = 0;
+  #my $columns;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  
+  #while ( $columns = $csv->getline($fh) ) {
+  my $line;
+  while ( defined($line=<$fh>) ) {
+
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+    #warn join('-',@columns);
+
+    my %row = ();
+    foreach my $field ( @fields ) {
+      $row{$field} = shift @columns;
+    }
+
+    my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
+    unless ( $cust_main ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "unknown custnum $row{'custnum'}";
+    }
+
+    if ( $row{'amount'} > 0 ) {
+      my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+      $imported++;
+    } elsif ( $row{'amount'} < 0 ) {
+      my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
+                                      $row{'pkg'}                         );
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+      $imported++;
+    } else {
+      #hmm?
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
 =back
 
 =head1 BUGS