one-time charge enhancements
[freeside.git] / FS / FS / cust_main.pm
index 7962f6d..0534686 100644 (file)
@@ -42,7 +42,7 @@ use FS::cust_bill_pay;
 use FS::prepay_credit;
 use FS::queue;
 use FS::part_pkg;
-use FS::part_bill_event;
+use FS::part_bill_event qw(due_events);
 use FS::cust_bill_event;
 use FS::cust_tax_exempt;
 use FS::cust_tax_exempt_pkg;
@@ -50,8 +50,9 @@ use FS::type_pkgs;
 use FS::payment_gateway;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
+use FS::payinfo_Mixin;
 
-@ISA = qw( FS::Record );
+@ISA = qw( FS::Record FS::payinfo_Mixin );
 
 @EXPORT_OK = qw( smart_search );
 
@@ -189,81 +190,15 @@ FS::Record.  The following fields are currently supported:
 
 =item ship_fax - phone (optional)
 
-=item payby 
+=item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
 
-I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to I<BILL>)
-
-=item payinfo 
-
-Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
-
-=cut 
-
-sub payinfo {
-  my($self,$payinfo) = @_;
-  if ( defined($payinfo) ) {
-    $self->paymask($payinfo);
-    $self->setfield('payinfo', $payinfo); # This is okay since we are the 'setter'
-  } else {
-    $payinfo = $self->getfield('payinfo'); # This is okay since we are the 'getter'
-    return $payinfo;
-  }
-}
+=item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
 
+=item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
 
 =item paycvv
-Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
-
-=cut
-
-=item paymask - Masked payment type
-
-=over 4 
-
-=item Credit Cards
-
-Mask all but the last four characters.
-
-=item Checks
 
-Mask all but last 2 of account number and bank routing number.
-
-=item Others
-
-Do nothing, return the unmasked string.
-
-=back
-
-=cut 
-
-sub paymask {
-  my($self,$value)=@_;
-
-  # If it doesn't exist then generate it
-  my $paymask=$self->getfield('paymask');
-  if (!defined($value) && (!defined($paymask) || $paymask eq '')) {
-    $value = $self->payinfo;
-  }
-
-  if ( defined($value) && !$self->is_encrypted($value)) {
-    my $payinfo = $value;
-    my $payby = $self->payby;
-    if ($payby eq 'CARD' || $payby eq 'DCRD') { # Credit Cards (Show last four)
-      $paymask = 'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
-    } elsif ($payby eq 'CHEK' ||
-             $payby eq 'DCHK' ) { # Checks (Show last 2 @ bank)
-      my( $account, $aba ) = split('@', $payinfo );
-      $paymask = 'x'x(length($account)-2). substr($account,(length($account)-2))."@".$aba;
-    } else { # Tie up loose ends
-      $paymask = $payinfo;
-    }
-    $self->setfield('paymask', $paymask); # This is okay since we are the 'setter'
-  } elsif (defined($value) && $self->is_encrypted($value)) {
-    $paymask = 'N/A';
-  }
-  return $paymask;
-}
+Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
 
 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
 
@@ -397,6 +332,8 @@ sub insert {
   warn "  inserting $self\n"
     if $DEBUG > 1;
 
+  $self->signupdate(time) unless $self->signupdate;
+
   my $error = $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
@@ -416,6 +353,20 @@ sub insert {
     $self->invoicing_list( $invoicing_list );
   }
 
+  if (    $conf->config('cust_main-skeleton_tables')
+       && $conf->config('cust_main-skeleton_custnum') ) {
+
+    warn "  inserting skeleton records\n"
+      if $DEBUG > 1;
+
+    my $error = $self->start_copy_skel;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
   warn "  ordering packages\n"
     if $DEBUG > 1;
 
@@ -458,6 +409,133 @@ sub insert {
 
 }
 
+sub start_copy_skel {
+  my $self = shift;
+
+  #'mg_user_preference' => {},
+  #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
+  #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
+  #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
+  #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
+  my @tables = eval($conf->config_binary('cust_main-skeleton_tables'));
+  die $@ if $@;
+
+  _copy_skel( 'cust_main',                                 #tablename
+              $conf->config('cust_main-skeleton_custnum'), #sourceid
+              $self->custnum,                              #destid
+              @tables,                                     #child tables
+            );
+}
+
+#recursive subroutine, not a method
+sub _copy_skel {
+  my( $table, $sourceid, $destid, %child_tables ) = @_;
+
+  my $primary_key;
+  if ( $table =~ /^(\w+)\.(\w+)$/ ) {
+    ( $table, $primary_key ) = ( $1, $2 );
+  } else {
+    my $dbdef_table = dbdef->table($table);
+    $primary_key = $dbdef_table->primary_key
+      or return "$table has no primary key".
+                " (or do you need to run dbdef-create?)";
+  }
+
+  warn "  _copy_skel: $table.$primary_key $sourceid to $destid for ".
+       join (', ', keys %child_tables). "\n"
+    if $DEBUG > 2;
+
+  foreach my $child_table_def ( keys %child_tables ) {
+
+    my $child_table;
+    my $child_pkey = '';
+    if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
+      ( $child_table, $child_pkey ) = ( $1, $2 );
+    } else {
+      $child_table = $child_table_def;
+
+      $child_pkey = dbdef->table($child_table)->primary_key;
+      #  or return "$table has no primary key".
+      #            " (or do you need to run dbdef-create?)\n";
+    }
+
+    my $sequence = '';
+    if ( keys %{ $child_tables{$child_table_def} } ) {
+
+      return "$child_table has no primary key".
+             " (run dbdef-create or try specifying it?)\n"
+        unless $child_pkey;
+
+      #false laziness w/Record::insert and only works on Pg
+      #refactor the proper last-inserted-id stuff out of Record::insert if this
+      # ever gets use for anything besides a quick kludge for one customer
+      my $default = dbdef->table($child_table)->column($child_pkey)->default;
+      $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
+        or return "can't parse $child_table.$child_pkey default value ".
+                  " for sequence name: $default";
+      $sequence = $1;
+
+    }
+  
+    my @sel_columns = grep { $_ ne $primary_key }
+                           dbdef->table($child_table)->columns;
+    my $sel_columns = join(', ', @sel_columns );
+
+    my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
+    my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
+    my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
+
+    my $sel_st = "SELECT $sel_columns FROM $child_table".
+                 " WHERE $primary_key = $sourceid";
+    warn "    $sel_st\n"
+      if $DEBUG > 2;
+    my $sel_sth = dbh->prepare( $sel_st )
+      or return dbh->errstr;
+  
+    $sel_sth->execute or return $sel_sth->errstr;
+
+    while ( my $row = $sel_sth->fetchrow_hashref ) {
+
+      warn "    selected row: ".
+           join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
+        if $DEBUG > 2;
+
+      my $statement =
+        "INSERT INTO $child_table $ins_columns VALUES $placeholders";
+      my $ins_sth =dbh->prepare($statement)
+          or return dbh->errstr;
+      my @param = ( $destid, map $row->{$_}, @ins_columns );
+      warn "    $statement: [ ". join(', ', @param). " ]\n"
+        if $DEBUG > 2;
+      $ins_sth->execute( @param )
+        or return $ins_sth->errstr;
+
+      #next unless keys %{ $child_tables{$child_table} };
+      next unless $sequence;
+      
+      #another section of that laziness
+      my $seq_sql = "SELECT currval('$sequence')";
+      my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
+      $seq_sth->execute or return $seq_sth->errstr;
+      my $insertid = $seq_sth->fetchrow_arrayref->[0];
+  
+      # don't drink soap!  recurse!  recurse!  okay!
+      my $error =
+        _copy_skel( $child_table_def,
+                    $row->{$child_pkey}, #sourceid
+                    $insertid, #destid
+                    %{ $child_tables{$child_table_def} },
+                  );
+      return $error if $error;
+
+    }
+
+  }
+
+  return '';
+
+}
+
 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
 
 Like the insert method on an existing record, this method orders a package
@@ -549,21 +627,23 @@ sub order_pkgs {
   ''; #no error
 }
 
-=item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF ]
+=item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
 
 Recharges this (existing) customer with the specified prepaid card (see
 L<FS::prepay_credit>), specified either by I<identifier> or as an
 FS::prepay_credit object.  If there is an error, returns the error, otherwise
 returns false.
 
-Optionally, two scalar references can be passed as well.  They will have their
-values filled in with the amount and number of seconds applied by this prepaid
+Optionally, four scalar references can be passed as well.  They will have their
+values filled in with the amount, number of seconds, and number of upload and
+download bytes applied by this prepaid
 card.
 
 =cut
 
 sub recharge_prepay { 
-  my( $self, $prepay_credit, $amountref, $secondsref ) = @_;
+  my( $self, $prepay_credit, $amountref, $secondsref, 
+      $upbytesref, $downbytesref, $totalbytesref ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -576,10 +656,14 @@ sub recharge_prepay {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my( $amount, $seconds ) = ( 0, 0 );
+  my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
 
-  my $error = $self->get_prepay($prepay_credit, \$amount, \$seconds)
+  my $error = $self->get_prepay($prepay_credit, \$amount,
+                                \$seconds, \$upbytes, \$downbytes, \$totalbytes)
            || $self->increment_seconds($seconds)
+           || $self->increment_upbytes($upbytes)
+           || $self->increment_downbytes($downbytes)
+           || $self->increment_totalbytes($totalbytes)
            || $self->insert_cust_pay_prepay( $amount,
                                              ref($prepay_credit)
                                                ? $prepay_credit->identifier
@@ -593,6 +677,9 @@ sub recharge_prepay {
 
   if ( defined($amountref)  ) { $$amountref  = $amount;  }
   if ( defined($secondsref) ) { $$secondsref = $seconds; }
+  if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
+  if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
+  if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
@@ -616,7 +703,8 @@ If there is an error, returns the error, otherwise returns false.
 
 
 sub get_prepay {
-  my( $self, $prepay_credit, $amountref, $secondsref ) = @_;
+  my( $self, $prepay_credit, $amountref, $secondsref,
+      $upref, $downref, $totalref) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -663,12 +751,51 @@ sub get_prepay {
 
   $$amountref  += $prepay_credit->amount;
   $$secondsref += $prepay_credit->seconds;
+  $$upref      += $prepay_credit->upbytes;
+  $$downref    += $prepay_credit->downbytes;
+  $$totalref   += $prepay_credit->totalbytes;
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
 }
 
+=item increment_upbytes SECONDS
+
+Updates this customer's single or primary account (see L<FS::svc_acct>) by
+the specified number of upbytes.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+sub increment_upbytes {
+  _increment_column( shift, 'upbytes', @_);
+}
+
+=item increment_downbytes SECONDS
+
+Updates this customer's single or primary account (see L<FS::svc_acct>) by
+the specified number of downbytes.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+sub increment_downbytes {
+  _increment_column( shift, 'downbytes', @_);
+}
+
+=item increment_totalbytes SECONDS
+
+Updates this customer's single or primary account (see L<FS::svc_acct>) by
+the specified number of totalbytes.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+sub increment_totalbytes {
+  _increment_column( shift, 'totalbytes', @_);
+}
+
 =item increment_seconds SECONDS
 
 Updates this customer's single or primary account (see L<FS::svc_acct>) by
@@ -678,10 +805,24 @@ otherwise returns false.
 =cut
 
 sub increment_seconds {
-  my( $self, $seconds ) = @_;
-  warn "$me increment_seconds called: $seconds seconds\n"
+  _increment_column( shift, 'seconds', @_);
+}
+
+=item _increment_column AMOUNT
+
+Updates this customer's single or primary account (see L<FS::svc_acct>) by
+the specified number of seconds or bytes.  If there is an error, returns
+the error, otherwise returns false.
+
+=cut
+
+sub _increment_column {
+  my( $self, $column, $amount ) = @_;
+  warn "$me increment_column called: $column, $amount\n"
     if $DEBUG;
 
+  return '' unless $amount;
+
   my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
                       $self->ncancelled_pkgs;
 
@@ -711,7 +852,8 @@ sub increment_seconds {
        ' ('. $svc_acct->email. ")\n"
     if $DEBUG > 1;
 
-  $svc_acct->increment_seconds($seconds);
+  $column = "increment_$column";
+  $svc_acct->$column($amount);
 
 }
 
@@ -934,11 +1076,6 @@ sub replace {
   local $SIG{TSTP} = 'IGNORE';
   local $SIG{PIPE} = 'IGNORE';
 
-  # If the mask is blank then try to set it - if we can...
-  if (!defined($self->getfield('paymask')) || $self->getfield('paymask') eq '') {
-    $self->paymask($self->payinfo);
-  }
-
   # We absolutely have to have an old vs. new record to make this work.
   if (!defined($old)) {
     $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
@@ -956,7 +1093,7 @@ sub replace {
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
     && $self->payby =~ /^(CARD|DCRD)$/
-    && $old->payinfo eq $self->payinfo;
+    && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
@@ -1023,15 +1160,19 @@ sub queue_fuzzyfiles_update {
   my $dbh = dbh;
 
   my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-  my $error = $queue->insert($self->getfield('last'), $self->company);
+  my $error = $queue->insert( map $self->getfield($_),
+                                  qw(first last company)
+                            );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "queueing job (transaction rolled back): $error";
   }
 
-  if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
+  if ( $self->ship_last ) {
     $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-    $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
+    $error = $queue->insert( map $self->getfield("ship_$_"),
+                                 qw(first last company)
+                           );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1064,6 +1205,8 @@ sub check {
     || $self->ut_number('refnum')
     || $self->ut_name('last')
     || $self->ut_name('first')
+    || $self->ut_snumbern('birthdate')
+    || $self->ut_snumbern('signupdate')
     || $self->ut_textn('company')
     || $self->ut_text('address1')
     || $self->ut_textn('address2')
@@ -1178,7 +1321,10 @@ sub check {
     }
   }
 
-  $self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
+  #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
+  #  or return "Illegal payby: ". $self->payby;
+  #$self->payby($1);
+  FS::payby->can_payby($self->table, $self->payby)
     or return "Illegal payby: ". $self->payby;
 
   $error =    $self->ut_numbern('paystart_month')
@@ -1203,8 +1349,6 @@ sub check {
     $check_payinfo = 0;
   }
 
-  $self->payby($1);
-
   if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
     my $payinfo = $self->payinfo;
@@ -1227,20 +1371,18 @@ sub check {
              ' (ban# '. $ban->bannum. ')';
     }
 
-    if ( defined $self->dbdef_table->column('paycvv') ) {
-      if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
-        if ( cardtype($self->payinfo) eq 'American Express card' ) {
-          $self->paycvv =~ /^(\d{4})$/
-            or return "CVV2 (CID) for American Express cards is four digits.";
-          $self->paycvv($1);
-        } else {
-          $self->paycvv =~ /^(\d{3})$/
-            or return "CVV2 (CVC2/CID) is three digits.";
-          $self->paycvv($1);
-        }
+    if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
+      if ( cardtype($self->payinfo) eq 'American Express card' ) {
+        $self->paycvv =~ /^(\d{4})$/
+          or return "CVV2 (CID) for American Express cards is four digits.";
+        $self->paycvv($1);
       } else {
-        $self->paycvv('');
+        $self->paycvv =~ /^(\d{3})$/
+          or return "CVV2 (CVC2/CID) is three digits.";
+        $self->paycvv($1);
       }
+    } else {
+      $self->paycvv('');
     }
 
     my $cardtype = cardtype($payinfo);
@@ -1279,7 +1421,7 @@ sub check {
       $payinfo = "$1\@$2";
     }
     $self->payinfo($payinfo);
-    $self->paycvv('') if $self->dbdef_table->column('paycvv');
+    $self->paycvv('');
 
     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
     if ( $ban ) {
@@ -1296,13 +1438,13 @@ sub check {
     $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
     $payinfo = $1;
     $self->payinfo($payinfo);
-    $self->paycvv('') if $self->dbdef_table->column('paycvv');
+    $self->paycvv('');
 
   } elsif ( $self->payby eq 'BILL' ) {
 
     $error = $self->ut_textn('payinfo');
     return "Illegal P.O. number: ". $self->payinfo if $error;
-    $self->paycvv('') if $self->dbdef_table->column('paycvv');
+    $self->paycvv('');
 
   } elsif ( $self->payby eq 'COMP' ) {
 
@@ -1316,7 +1458,7 @@ sub check {
 
     $error = $self->ut_textn('payinfo');
     return "Illegal comp account issuer: ". $self->payinfo if $error;
-    $self->paycvv('') if $self->dbdef_table->column('paycvv');
+    $self->paycvv('');
 
   } elsif ( $self->payby eq 'PREPAY' ) {
 
@@ -1327,7 +1469,7 @@ sub check {
     return "Illegal prepayment identifier: ". $self->payinfo if $error;
     return "Unknown prepayment identifier"
       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
-    $self->paycvv('') if $self->dbdef_table->column('paycvv');
+    $self->paycvv('');
 
   }
 
@@ -1384,11 +1526,17 @@ Returns all packages (see L<FS::cust_pkg>) for this customer.
 
 sub all_pkgs {
   my $self = shift;
+
+  return $self->num_pkgs unless wantarray;
+
+  my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
-    values %{ $self->{'_pkgnum'}->cache };
+    @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
   } else {
-    qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
+    @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
   }
+
+  sort sort_packages @cust_pkg;
 }
 
 =item ncancelled_pkgs
@@ -1399,19 +1547,43 @@ Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
 
 sub ncancelled_pkgs {
   my $self = shift;
+
+  return $self->num_ncancelled_pkgs unless wantarray;
+
+  my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
-    grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
+
+    @cust_pkg = grep { ! $_->getfield('cancel') }
+                values %{ $self->{'_pkgnum'}->cache };
+
   } else {
-    @{ [ # force list context
+
+    @cust_pkg =
       qsearch( 'cust_pkg', {
-        'custnum' => $self->custnum,
-        'cancel'  => '',
-      }),
+                             'custnum' => $self->custnum,
+                             'cancel'  => '',
+                           });
+    push @cust_pkg,
       qsearch( 'cust_pkg', {
-        'custnum' => $self->custnum,
-        'cancel'  => 0,
-      }),
-    ] };
+                             'custnum' => $self->custnum,
+                             'cancel'  => 0,
+                           });
+  }
+
+  sort sort_packages @cust_pkg;
+
+}
+
+# This should be generalized to use config options to determine order.
+sub sort_packages {
+  if ( $a->get('cancel') and $b->get('cancel') ) {
+    $a->pkgnum <=> $b->pkgnum;
+  } elsif ( $a->get('cancel') or $b->get('cancel') ) {
+    return -1 if $b->get('cancel');
+    return  1 if $a->get('cancel');
+    return 0;
+  } else {
+    $a->pkgnum <=> $b->pkgnum;
   }
 }
 
@@ -1460,14 +1632,18 @@ customer.
 =cut
 
 sub num_cancelled_pkgs {
-  my $self = shift;
-  $self->num_pkgs("cancel IS NOT NULL AND cust_pkg.cancel != 0");
+  shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
+}
+
+sub num_ncancelled_pkgs {
+  shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
 }
 
 sub num_pkgs {
   my( $self, $sql ) = @_;
+  $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
   my $sth = dbh->prepare(
-    "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? AND $sql"
+    "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
   ) or die dbh->errstr;
   $sth->execute($self->custnum) or die $sth->errstr;
   $sth->fetchrow_arrayref->[0];
@@ -1496,7 +1672,7 @@ Returns a list: an empty list on success or a list of errors.
 
 sub suspend {
   my $self = shift;
-  grep { $_->suspend } $self->unsuspended_pkgs;
+  grep { $_->suspend(@_) } $self->unsuspended_pkgs;
 }
 
 =item suspend_if_pkgpart PKGPART [ , PKGPART ... ]
@@ -1510,8 +1686,14 @@ Returns a list: an empty list on success or a list of errors.
 
 sub suspend_if_pkgpart {
   my $self = shift;
-  my @pkgparts = @_;
-  grep { $_->suspend }
+  my (@pkgparts, %opt);
+  if (ref($_[0]) eq 'HASH'){
+    @pkgparts = @{$_[0]{pkgparts}};
+    %opt      = %{$_[0]};
+  }else{
+    @pkgparts = @_;
+  }
+  grep { $_->suspend(%opt) }
     grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
       $self->unsuspended_pkgs;
 }
@@ -1527,8 +1709,14 @@ Returns a list: an empty list on success or a list of errors.
 
 sub suspend_unless_pkgpart {
   my $self = shift;
-  my @pkgparts = @_;
-  grep { $_->suspend }
+  my (@pkgparts, %opt);
+  if (ref($_[0]) eq 'HASH'){
+    @pkgparts = @{$_[0]{pkgparts}};
+    %opt      = %{$_[0]};
+  }else{
+    @pkgparts = @_;
+  }
+  grep { $_->suspend(%opt) }
     grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
       $self->unsuspended_pkgs;
 }
@@ -1583,10 +1771,26 @@ sub _banned_pay_hashref {
   {
     'payby'   => $payby2ban{$self->payby},
     'payinfo' => md5_base64($self->payinfo),
-    #'reason'  =>
+    #don't ever *search* on reason! #'reason'  =>
   };
 }
 
+=item notes
+
+Returns all notes (see L<FS::cust_main_note>) for this customer.
+
+=cut
+
+sub notes {
+  my $self = shift;
+  #order by?
+  qsearch( 'cust_main_note',
+           { 'custnum' => $self->custnum },
+          '',
+          'ORDER BY _DATE DESC'
+        );
+}
+
 =item agent
 
 Returns the agent (see L<FS::agent>) for this customer.
@@ -1700,7 +1904,7 @@ sub bill {
     
       warn "    bill setup\n" if $DEBUG > 1;
 
-      $setup = eval { $cust_pkg->calc_setup( $time ) };
+      $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
       if ( $@ ) {
         $dbh->rollback if $oldAutoCommit;
         return "$@ running calc_setup for $cust_pkg\n";
@@ -2106,79 +2310,28 @@ sub collect {
     warn "  invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")\n"
       if $DEBUG > 1;
 
-    foreach my $part_bill_event (
-      sort {    $a->seconds   <=> $b->seconds
-             || $a->weight    <=> $b->weight
-             || $a->eventpart <=> $b->eventpart }
-        grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
-               && ! qsearch( 'cust_bill_event', {
-                                'invnum'    => $cust_bill->invnum,
-                                'eventpart' => $_->eventpart,
-                                'status'    => 'done',
-                                                                   } )
-             }
-          qsearch( {
-            'table'     => 'part_bill_event',
-            'hashref'   => { 'payby'    => (exists($options{'payby'})
-                                            ? $options{'payby'}
-                                            : $self->payby
-                                          ),
-                             'disabled' => '',           },
-            'extra_sql' => $extra_sql,
-          } )
-    ) {
+    foreach my $part_bill_event ( due_events ( $cust_bill,
+                                               exists($options{'payby'}) 
+                                                ? $options{'payby'}
+                                                : $self->payby,
+                                              $invoice_time,
+                                              $extra_sql ) ) {
 
       last if $cust_bill->owed <= 0  # don't run subsequent events if owed<=0
            || $self->balance   <= 0; # or if balance<=0
 
-      warn "  calling invoice event (". $part_bill_event->eventcode. ")\n"
-        if $DEBUG > 1;
-      my $cust_main = $self; #for callback
-
-      my $error;
       {
         local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
-        local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
-        $error = eval $part_bill_event->eventcode;
-      }
-
-      my $status = '';
-      my $statustext = '';
-      if ( $@ ) {
-        $status = 'failed';
-        $statustext = $@;
-      } elsif ( $error ) {
-        $status = 'done';
-        $statustext = $error;
-      } else {
-        $status = 'done'
-      }
-
-      #add cust_bill_event
-      my $cust_bill_event = new FS::cust_bill_event {
-        'invnum'     => $cust_bill->invnum,
-        'eventpart'  => $part_bill_event->eventpart,
-        #'_date'      => $invoice_time,
-        '_date'      => time,
-        'status'     => $status,
-        'statustext' => $statustext,
-      };
-      $error = $cust_bill_event->insert;
-      if ( $error ) {
-        #$dbh->rollback if $oldAutoCommit;
-        #return "error: $error";
+        warn "  do_event " .  $cust_bill . " ". (%options) .  "\n"
+          if $DEBUG > 1;
 
-        # gah, even with transactions.
-        $dbh->commit if $oldAutoCommit; #well.
-        my $e = 'WARNING: Event run but database not updated - '.
-                'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
-                ', eventpart '. $part_bill_event->eventpart.
-                ": $error";
-        warn $e;
-        return $e;
+        if (my $error = $part_bill_event->do_event($cust_bill, %options)) {
+         # gah, even with transactions.
+         $dbh->commit if $oldAutoCommit; #well.
+         return $error;
+       }
       }
 
-
     }
 
   }
@@ -2190,9 +2343,10 @@ sub collect {
 
 =item retry_realtime
 
-Schedules realtime credit card / electronic check / LEC billing events for
-for retry.  Useful if card information has changed or manual retry is desired.
-The 'collect' method must be called to actually retry the transaction.
+Schedules realtime / batch  credit card / electronic check / LEC billing
+events for for retry.  Useful if card information has changed or manual
+retry is desired.  The 'collect' method must be called to actually retry
+the transaction.
 
 Implementation details: For each of this customer's open invoices, changes
 the status of the first "done" (with statustext error) realtime processing
@@ -2223,7 +2377,7 @@ sub retry_realtime {
         grep {
                #$_->part_bill_event->plan eq 'realtime-card'
                $_->part_bill_event->eventcode =~
-                   /\$cust_bill\->realtime_(card|ach|lec)/
+                   /\$cust_bill\->(batch|realtime)_(card|ach|lec)/
                  && $_->status eq 'done'
                  && $_->statustext
              }
@@ -2391,6 +2545,9 @@ sub realtime_bop {
   $content{customer_ip} = $payip
     if length($payip);
 
+  $content{invoice_number} = $options{'invnum'}
+    if exists($options{'invnum'}) && length($options{'invnum'});
+
   if ( $method eq 'CC' ) { 
 
     $content{card_number} = $payinfo;
@@ -2426,8 +2583,13 @@ sub realtime_bop {
       if qsearch('cust_pay', { 'custnum' => $self->custnum,
                                'payby'   => 'CARD',
                                'payinfo' => $payinfo,
+                             } )
+      || qsearch('cust_pay', { 'custnum' => $self->custnum,
+                               'payby'   => 'CARD',
+                               'paymask' => $self->mask_payinfo('CARD', $payinfo),
                              } );
 
+
   } elsif ( $method eq 'ECHECK' ) {
     ( $content{account_number}, $content{routing_code} ) =
       split('@', $payinfo);
@@ -2456,7 +2618,7 @@ sub realtime_bop {
     'action'         => $action1,
     'description'    => $options{'description'},
     'amount'         => $amount,
-    'invoice_number' => $options{'invnum'},
+    #'invoice_number' => $options{'invnum'},
     'customer_id'    => $self->custnum,
     'last_name'      => $paylast,
     'first_name'     => $payfirst,
@@ -2502,7 +2664,8 @@ sub realtime_bop {
       description    => $options{'description'},
     );
 
-    foreach my $field (qw( authorization_source_code returned_ACI                                          transaction_identifier validation_code           
+    foreach my $field (qw( authorization_source_code returned_ACI
+                           transaction_identifier validation_code           
                            transaction_sequence_num local_transaction_date    
                            local_transaction_time AVS_result_code          )) {
       $capture{$field} = $transaction->$field() if $transaction->can($field);
@@ -2850,6 +3013,22 @@ sub realtime_refund_bop {
     $payname =  "$payfirst $paylast";
   }
 
+  my @invoicing_list = grep { $_ ne 'POST' } $self->invoicing_list;
+  if ( $conf->exists('emailinvoiceauto')
+       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+    push @invoicing_list, $self->all_emails;
+  }
+
+  my $email = ($conf->exists('business-onlinepayment-email-override'))
+              ? $conf->config('business-onlinepayment-email-override')
+              : $invoicing_list[0];
+
+  my $payip = exists($options{'payip'})
+                ? $options{'payip'}
+                : $self->payip;
+  $content{customer_ip} = $payip
+    if length($payip);
+
   my $payinfo = '';
   if ( $method eq 'CC' ) {
 
@@ -2888,6 +3067,8 @@ sub realtime_refund_bop {
     'state'          => $self->state,
     'zip'            => $self->zip,
     'country'        => $self->country,
+    'email'          => $email,
+    'phone'          => $self->daytime || $self->night,
     %content, #after
   );
   warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
@@ -3190,21 +3371,6 @@ sub paydate_monthyear {
   }
 }
 
-=item payinfo_masked
-
-Returns a "masked" payinfo field appropriate to the payment type.  Masked characters are replaced by 'x'es.  Use this to display publicly accessable account Information.
-
-Credit Cards - Mask all but the last four characters.
-Checks - Mask all but last 2 of account number and bank routing number.
-Others - Do nothing, return the unmasked string.
-
-=cut
-
-sub payinfo_masked {
-  my $self = shift;
-  return $self->paymask;
-}
-
 =item invoicing_list [ ARRAYREF ]
 
 If an arguement is given, sets these email addresses as invoice recipients
@@ -3449,10 +3615,22 @@ the error, otherwise returns false.
 =cut
 
 sub charge {
-  my ( $self, $amount ) = ( shift, shift );
-  my $pkg      = @_ ? shift : 'One-time charge';
-  my $comment  = @_ ? shift : '$'. sprintf("%.2f",$amount);
-  my $taxclass = @_ ? shift : '';
+  my $self = shift;
+  my ( $amount, $pkg, $comment, $taxclass, $additional );
+  if ( ref( $_[0] ) ) {
+    $amount     = $_[0]->{amount};
+    $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
+    $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
+                                           : '$'. sprintf("%.2f",$amount);
+    $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
+    $additional = $_[0]->{additional};
+  }else{
+    $amount     = shift;
+    $pkg        = @_ ? shift : 'One-time charge';
+    $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
+    $taxclass   = @_ ? shift : '';
+    $additional = [];
+  }
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -3468,16 +3646,20 @@ sub charge {
   my $part_pkg = new FS::part_pkg ( {
     'pkg'      => $pkg,
     'comment'  => $comment,
-    #'setup'    => $amount,
-    #'recur'    => '0',
     'plan'     => 'flat',
-    'plandata' => "setup_fee=$amount",
     'freq'     => 0,
     'disabled' => 'Y',
     'taxclass' => $taxclass,
   } );
 
-  my $error = $part_pkg->insert;
+  my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
+                        ( 0 .. @$additional - 1 )
+                  ),
+                  'additional_count' => scalar(@$additional),
+                  'setup_fee' => $amount,
+                );
+
+  my $error = $part_pkg->insert( options => \%options );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -3839,12 +4021,12 @@ sub fuzzy_search {
 
   check_and_rebuild_fuzzyfiles();
   foreach my $field ( keys %$fuzzy ) {
+
+    my $all = $self->all_X($field);
+    next unless scalar(@$all);
+
     my %match = ();
-    $match{$_}=1 foreach ( amatch( $fuzzy->{$field},
-                                   ['i'],
-                                   @{ $self->all_X($field) }
-                                 )
-                         );
+    $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
 
     my @fcust = ();
     foreach ( keys %match ) {
@@ -3873,10 +4055,10 @@ sub fuzzy_search {
 
 Accepts the following options: I<search>, the string to search for.  The string
 will be searched for as a customer number, phone number, name or company name,
-first searching for an exact match then fuzzy and substring matches (in some
-cases - see the source code for the exact heuristics used).
+as an exact, or, in some cases, a substring or fuzzy match (see the source code
+for the exact heuristics used).
 
-Any additional options treated as an additional qualifier on the search
+Any additional options are treated as an additional qualifier on the search
 (i.e. I<agentnum>).
 
 Returns a (possibly empty) array of FS::cust_main objects.
@@ -4026,7 +4208,9 @@ sub smart_search {
       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
     } );
 
-    unless ( @cust_main ) {  #no exact match, trying substring/fuzzy
+    #always do substring & fuzzy,
+    #getting complains searches are not returning enough
+    #unless ( @cust_main ) {  #no exact match, trying substring/fuzzy
 
       #still some false laziness w/ search/cust_main.cgi
 
@@ -4087,7 +4271,7 @@ sub smart_search {
           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
       }
 
-    }
+    #}
 
     #eliminate duplicates
     my %saw = ();
@@ -4128,14 +4312,21 @@ sub rebuild_fuzzyfiles {
       or die "can't open $dir/cust_main.$fuzzy: $!";
     flock(LOCK,LOCK_EX)
       or die "can't lock $dir/cust_main.$fuzzy: $!";
-  
-    my @all = map $_->getfield($fuzzy), qsearch('cust_main', {});
-    push @all,
-      grep $_, map $_->getfield("ship_$fuzzy"), qsearch('cust_main',{});
-  
+
     open (CACHE,">$dir/cust_main.$fuzzy.tmp")
       or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
-    print CACHE join("\n", @all), "\n";
+
+    foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
+      my $sth = dbh->prepare("SELECT $field FROM cust_main".
+                             " WHERE $field != '' AND $field IS NOT NULL");
+      $sth->execute or die $sth->errstr;
+
+      while ( my $row = $sth->fetchrow_arrayref ) {
+        print CACHE $row->[0]. "\n";
+      }
+
+    } 
+
     close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
   
     rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
@@ -4163,7 +4354,7 @@ sub all_X {
 =cut
 
 sub append_fuzzyfiles {
-  my( $last, $company ) = @_;
+  #my( $first, $last, $company ) = @_;
 
   &check_and_rebuild_fuzzyfiles;
 
@@ -4171,33 +4362,23 @@ sub append_fuzzyfiles {
 
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
 
-  if ( $last ) {
+  foreach my $field (qw( first last company )) {
+    my $value = shift;
 
-    open(LAST,">>$dir/cust_main.last")
-      or die "can't open $dir/cust_main.last: $!";
-    flock(LAST,LOCK_EX)
-      or die "can't lock $dir/cust_main.last: $!";
+    if ( $value ) {
 
-    print LAST "$last\n";
+      open(CACHE,">>$dir/cust_main.$field")
+        or die "can't open $dir/cust_main.$field: $!";
+      flock(CACHE,LOCK_EX)
+        or die "can't lock $dir/cust_main.$field: $!";
 
-    flock(LAST,LOCK_UN)
-      or die "can't unlock $dir/cust_main.last: $!";
-    close LAST;
-  }
-
-  if ( $company ) {
-
-    open(COMPANY,">>$dir/cust_main.company")
-      or die "can't open $dir/cust_main.company: $!";
-    flock(COMPANY,LOCK_EX)
-      or die "can't lock $dir/cust_main.company: $!";
-
-    print COMPANY "$company\n";
+      print CACHE "$value\n";
 
-    flock(COMPANY,LOCK_UN)
-      or die "can't unlock $dir/cust_main.company: $!";
+      flock(CACHE,LOCK_UN)
+        or die "can't unlock $dir/cust_main.$field: $!";
+      close CACHE;
+    }
 
-    close COMPANY;
   }
 
   1;
@@ -4212,9 +4393,33 @@ sub batch_import {
   #warn join('-',keys %$param);
   my $fh = $param->{filehandle};
   my $agentnum = $param->{agentnum};
+
   my $refnum = $param->{refnum};
   my $pkgpart = $param->{pkgpart};
-  my @fields = @{$param->{fields}};
+
+  #my @fields = @{$param->{fields}};
+  my $format = $param->{'format'};
+  my @fields;
+  my $payby;
+  if ( $format eq 'simple' ) {
+    @fields = qw( cust_pkg.setup dayphone first last
+                  address1 address2 city state zip comments );
+    $payby = 'BILL';
+  } elsif ( $format eq 'extended' ) {
+    @fields = qw( agent_custid refnum
+                  last first address1 address2 city state zip country
+                  daytime night
+                  ship_last ship_first ship_address1 ship_address2
+                  ship_city ship_state ship_zip ship_country
+                  payinfo paycvv paydate
+                  invoicing_list
+                  cust_pkg.pkgpart
+                  svc_acct.username svc_acct._password 
+                );
+    $payby = 'BILL';
+  } else {
+    die "unknown format $format";
+  }
 
   eval "use Text::CSV_XS;";
   die $@ if $@;
@@ -4253,51 +4458,112 @@ sub batch_import {
       agentnum => $agentnum,
       refnum   => $refnum,
       country  => $conf->config('countrydefault') || 'US',
-      payby    => 'BILL', #default
+      payby    => $payby, #default
       paydate  => '12/2037', #default
     );
     my $billtime = time;
     my %cust_pkg = ( pkgpart => $pkgpart );
+    my %svc_acct = ();
     foreach my $field ( @fields ) {
-      if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
+
+      if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|expire|cancel)$/ ) {
+
         #$cust_pkg{$1} = str2time( shift @$columns );
-        if ( $1 eq 'setup' ) {
+        if ( $1 eq 'pkgpart' ) {
+          $cust_pkg{$1} = shift @columns;
+        } elsif ( $1 eq 'setup' ) {
           $billtime = str2time(shift @columns);
         } else {
           $cust_pkg{$1} = str2time( shift @columns );
-        }
+        } 
+
+      } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
+
+        $svc_acct{$1} = shift @columns;
+        
       } else {
+
+        #refnum interception
+        if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
+
+          my $referral = $columns[0];
+          my %hash = ( 'referral' => $referral,
+                       'agentnum' => $agentnum,
+                       'disabled' => '',
+                     );
+
+          my $part_referral = qsearchs('part_referral', \%hash )
+                              || new FS::part_referral \%hash;
+
+          unless ( $part_referral->refnum ) {
+            my $error = $part_referral->insert;
+            if ( $error ) {
+              $dbh->rollback if $oldAutoCommit;
+              return "can't auto-insert advertising source: $referral: $error";
+            }
+          }
+
+          $columns[0] = $part_referral->refnum;
+        }
+
         #$cust_main{$field} = shift @$columns; 
         $cust_main{$field} = shift @columns; 
       }
     }
 
-    my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
+    $cust_main{'payby'} = 'CARD' if length($cust_main{'payinfo'});
+
+    my $invoicing_list = $cust_main{'invoicing_list'}
+                           ? [ delete $cust_main{'invoicing_list'} ]
+                           : [];
+
     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";
+    if ( $cust_pkg{'pkgpart'} ) {
+      my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
+
+      my @svc_acct = ();
+      if ( $svc_acct{'username'} ) {
+        my $part_pkg = $cust_pkg->part_pkg;
+       unless ( $part_pkg ) {
+         $dbh->rollback if $oldAutoCommit;
+         return "unknown pkgnum ". $cust_pkg{'pkgpart'};
+       } 
+        $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
+        push @svc_acct, new FS::svc_acct ( \%svc_acct )
+      }
+
+      $hash{$cust_pkg} = \@svc_acct;
     }
 
-    #false laziness w/bill.cgi
-    $error = $cust_main->bill( 'time' => $billtime );
+    my $error = $cust_main->insert( \%hash, $invoicing_list );
+
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "can't bill customer for $line: $error";
+      return "can't insert customer for $line: $error";
     }
 
-    $cust_main->apply_payments;
-    $cust_main->apply_credits;
+    if ( $format eq 'simple' ) {
+
+      #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";
+      }
 
-    $error = $cust_main->collect();
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't collect customer for $line: $error";
     }
 
     $imported++;
@@ -4413,6 +4679,8 @@ No multiple currency support (probably a larger project than just this module).
 
 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
 
+Birthdates rely on negative epoch values.
+
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>