FS::cust_main::Billing_Discount
FS::cust_main::Billing_ThirdParty
FS::cust_main::Location
+ FS::cust_main::Credit_Limit
FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
FS::o2m_Common
FS::Record
);
-use vars qw( $DEBUG $me $conf
+use vars qw( $DEBUG $me $conf $default_agent_custid $custnum_display_length
@encrypted_fields
$import
$ignore_expired_card $ignore_banned_card $ignore_illegal_zip
use Date::Format;
#use Date::Manip;
use File::Temp; #qw( tempfile );
+use Email::Address;
use Business::CreditCard 0.28;
-use Locale::Country;
use FS::UID qw( getotaker dbh driver_name );
use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
-use FS::Misc qw( generate_email send_email generate_ps do_print );
+use FS::Misc qw( generate_email send_email generate_ps do_print money_pretty card_types );
use FS::Msgcat qw(gettext);
use FS::CurrentUser;
use FS::TicketSystem;
use FS::contact;
use FS::Locales;
use FS::upgrade_journal;
+use FS::reason;
# 1 is mostly method/subroutine entry and options
# 2 traces progress of some operations
#$FS::UID::callback{'FS::cust_main'} = sub {
install_callback FS::UID sub {
$conf = new FS::Conf;
- #yes, need it for stuff below (prolly should be cached)
+ $default_agent_custid = $conf->exists('cust_main-default_agent_custid');
+ $custnum_display_length = $conf->config('cust_main-custnum-display_length');
};
sub _cache {
IP address from which payment information was received
+=item paycardtype
+
+The credit card type (deduced from the card number).
+
=item tax
Tax exempt, empty or `Y'
Do not call, empty or 'Y'
+=item invoice_ship_address
+
+Display ship_address ("Service address") on invoices for this customer, empty or 'Y'
+
=back
=head1 METHODS
$payby = 'PREP' if $amount;
- } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|PPAL)$/ ) {
+ } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
$payby = $1;
$self->payby('BILL');
# insert locations
foreach my $l (qw(bill_location ship_location)) {
- my $loc = delete $self->hashref->{$l};
- # XXX if we're moving a prospect's locations, do that here
- if ( !$loc ) {
- return "$l not set";
- }
+
+ my $loc = delete $self->hashref->{$l} or return "$l not set";
if ( !$loc->locationnum ) {
# warn the location that we're going to insert it with no custnum
my $label = $l eq 'ship_location' ? 'service' : 'billing';
return "$error (in $label location)";
}
- }
- elsif ( ($loc->custnum || 0) > 0 or $loc->prospectnum ) {
+
+ } elsif ( $loc->prospectnum ) {
+
+ $loc->prospectnum('');
+ $loc->set(custnum_pending => 1);
+ my $error = $loc->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ my $label = $l eq 'ship_location' ? 'service' : 'billing';
+ return "$error (moving $label location)";
+ }
+
+ } elsif ( ($loc->custnum || 0) > 0 ) {
# then it somehow belongs to another customer--shouldn't happen
$dbh->rollback if $oldAutoCommit;
return "$l belongs to customer ".$loc->custnum;
$self->auto_agent_custid()
if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
- my $error = $self->SUPER::insert;
+ my $error = $self->check_payinfo_cardtype
+ || $self->SUPER::insert;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
#return "inserting cust_main record (transaction rolled back): $error";
}
+ # validate card (needs custnum already set)
+ if ( $self->payby =~ /^(CARD|DCRD)$/
+ && $conf->exists('business-onlinepayment-verification') ) {
+ $error = $self->realtime_verify_bop({ 'method'=>'CC' });
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ warn " setting contacts\n"
+ if $DEBUG > 1;
+
+ if ( my $contact = delete $options{'contact'} ) {
+
+ foreach my $c ( @$contact ) {
+ $c->custnum($self->custnum);
+ my $error = $c->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ }
+
+ } elsif ( my $contact_params = delete $options{'contact_params'} ) {
+
+ my $error = $self->process_o2m( 'table' => 'contact',
+ 'fields' => FS::contact->cgi_contact_fields,
+ 'params' => $contact_params,
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
warn " setting cust_main_exemption\n"
if $DEBUG > 1;
}
tie my %financial_tables, 'Tie::IxHash',
- 'cust_bill' => 'invoices',
- 'cust_bill_void' => 'voided invoices',
- 'cust_statement' => 'statements',
- 'cust_credit' => 'credits',
- 'cust_pay' => 'payments',
- 'cust_pay_void' => 'voided payments',
- 'cust_refund' => 'refunds',
+ 'cust_bill' => 'invoices',
+ 'cust_bill_void' => 'voided invoices',
+ 'cust_statement' => 'statements',
+ 'cust_credit' => 'credits',
+ 'cust_credit_void' => 'voided credits',
+ 'cust_pay' => 'payments',
+ 'cust_pay_void' => 'voided payments',
+ 'cust_refund' => 'refunds',
;
foreach my $table ( keys %financial_tables ) {
|| $old->payby =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
&& ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
+ if ( $self->payby =~ /^(CARD|DCRD)$/
+ && $old->payinfo ne $self->payinfo
+ && $old->paymask ne $self->paymask )
+ {
+ my $error = $self->check_payinfo_cardtype;
+ return $error if $error;
+
+ if ( $conf->exists('business-onlinepayment-verification') ) {
+ #need to standardize paydate for this, false laziness with check
+ my( $m, $y );
+ if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
+ ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+ } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+ ( $m, $y ) = ( $2, "19$1" );
+ } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+ ( $m, $y ) = ( $3, "20$2" );
+ } else {
+ return "Illegal expiration date: ". $self->paydate;
+ }
+ $m = sprintf('%02d',$m);
+ $self->paydate("$y-$m-01");
+
+ $error = $self->realtime_verify_bop({ 'method'=>'CC' });
+ return $error if $error;
+ }
+ }
+
return "Invoicing locale is required"
if $old->locale
&& ! $self->locale
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
+ foreach my $field ( 'first', 'last', 'company', 'ship_company' ) {
+ my $queue = new FS::queue {
+ 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
+ };
+ my @args = "cust_main.$field", $self->get($field);
+ my $error = $queue->insert( @args );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "queueing job (transaction rolled back): $error";
+ }
+ }
+
my @locations = $self->bill_location;
push @locations, $self->ship_location if $self->has_ship_address;
foreach my $location (@locations) {
my $queue = new FS::queue {
- 'job' => 'FS::cust_main::Search::append_fuzzyfiles'
+ 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
};
- my @args = map $location->get($_), @FS::cust_main::Search::fuzzyfields;
+ my @args = 'cust_location.address1', $location->address1;
my $error = $queue->insert( @args );
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
|| $self->ut_name('first')
|| $self->ut_snumbern('signupdate')
|| $self->ut_snumbern('birthdate')
+ || $self->ut_namen('spouse_last')
+ || $self->ut_namen('spouse_first')
|| $self->ut_snumbern('spouse_birthdate')
|| $self->ut_snumbern('anniversary_date')
|| $self->ut_textn('company')
+ || $self->ut_textn('ship_company')
|| $self->ut_anything('comments')
|| $self->ut_numbern('referral_custnum')
|| $self->ut_textn('stateid')
|| $self->ut_floatn('credit_limit')
|| $self->ut_numbern('billday')
|| $self->ut_numbern('prorate_day')
+ || $self->ut_flag('force_prorate_day')
|| $self->ut_flag('edit_subject')
|| $self->ut_flag('calling_list_exempt')
|| $self->ut_flag('invoice_noemail')
|| $self->ut_flag('message_noemail')
|| $self->ut_enum('locale', [ '', FS::Locales->locales ])
+ || $self->ut_flag('invoice_ship_address')
;
- my $company = $self->company;
- $company =~ s/^\s+//;
- $company =~ s/\s+$//;
- $company =~ s/\s+/ /g;
- $self->company($company);
+ foreach (qw(company ship_company)) {
+ my $company = $self->get($_);
+ $company =~ s/^\s+//;
+ $company =~ s/\s+$//;
+ $company =~ s/\s+/ /g;
+ $self->set($_, $company);
+ }
#barf. need message catalogs. i18n. etc.
$error .= "Please select an advertising source."
$self->ss("$1-$2-$3");
}
+ #turn off invoice_ship_address if ship & bill are the same
+ if ($self->bill_locationnum eq $self->ship_locationnum) {
+ $self->invoice_ship_address('');
+ }
+
# cust_main_county verification now handled by cust_location check
$error =
validate($payinfo)
or return gettext('invalid_card'); # . ": ". $self->payinfo;
- return gettext('unknown_card_type')
- if $self->payinfo !~ /^99\d{14}$/ #token
- && cardtype($self->payinfo) eq "Unknown";
+ my $cardtype = $self->paycardtype;
+ if ( $payinfo =~ /^99\d{14}$/ ) {
+ $self->set('is_tokenized', 'Y'); #so we don't try to do it again
+ if ( $self->paymask =~ /^\d+x/ ) {
+ $cardtype = cardtype($self->paymask);
+ } else {
+ #return "paycardtype required ".
+ # "(can't derive from a token and no paymask w/prefix provided)"
+ # unless $cardtype;
+ }
+ } else {
+ $cardtype = cardtype($self->payinfo);
+ }
+
+ return gettext('unknown_card_type') if $cardtype eq 'Unknown';
+
+ $self->set('paycardtype', $cardtype);
unless ( $ignore_banned_card ) {
my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
}
if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
- if ( cardtype($self->payinfo) eq 'American Express card' ) {
+ if ( $cardtype eq 'American Express card' ) {
$self->paycvv =~ /^(\d{4})$/
or return "CVV2 (CID) for American Express cards is four digits.";
$self->paycvv($1);
$self->paycvv('');
}
- my $cardtype = cardtype($payinfo);
if ( $cardtype =~ /^(Switch|Solo)$/i ) {
return "Start date or issue number is required for $cardtype cards"
unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
$self->paycvv('');
+ } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) {
+ # either ignoring invalid cards, or we can't decrypt the payinfo, but
+ # try to detect the card type anyway. this never returns failure, so
+ # the contract of $ignore_invalid_cards is maintained.
+ $self->set('paycardtype', cardtype($self->paymask));
}
if ( $self->paydate eq '' || $self->paydate eq '-' ) {
) {
$self->payname( $self->first. " ". $self->getfield('last') );
} else {
- $self->payname =~ /^([\w \,\.\-\'\&]+)$/
- or return gettext('illegal_name'). " payname: ". $self->payname;
- $self->payname($1);
+
+ if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
+ $self->payname =~ /^([\w \,\.\-\']*)$/
+ or return gettext('illegal_name'). " payname: ". $self->payname;
+ $self->payname($1);
+ } else {
+ $self->payname =~ /^([\w \,\.\-\'\&]*)$/
+ or return gettext('illegal_name'). " payname: ". $self->payname;
+ $self->payname($1);
+ }
+
}
return "Please select an invoicing locale"
&& ! $self->custnum
&& $conf->exists('cust_main-require_locale');
+ return "Please select a customer class"
+ if ! $self->classnum
+ && $conf->exists('cust_main-require_classnum');
+
foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
$self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
$self->$flag($1);
$self->SUPER::check;
}
+sub check_payinfo_cardtype {
+ my $self = shift;
+
+ return '' unless $self->payby =~ /^(CARD|DCRD)$/;
+
+ my $payinfo = $self->payinfo;
+ $payinfo =~ s/\D//g;
+
+ if ( $payinfo =~ /^99\d{14}$/ ) {
+ return '';
+ }
+
+ my %bop_card_types = map { $_=>1 } values %{ card_types() };
+ my $cardtype = cardtype($payinfo);
+ $self->set('paycardtype', $cardtype);
+
+ return "$cardtype not accepted" unless $bop_card_types{$cardtype};
+
+ '';
+
+}
+
+=item replace_check
+
+Additional checks for replace only.
+
+=cut
+
+sub replace_check {
+ my ($new,$old) = @_;
+ #preserve old value if global config is set
+ if ($old && $conf->exists('invoice-ship_address')) {
+ $new->invoice_ship_address($old->invoice_ship_address);
+ }
+ return '';
+}
+
=item addr_fields
Returns a list of fields which have ship_ duplicates.
sub addr_fields {
qw( last first company
+ locationname
address1 address2 city county state zip country
latitude longitude
daytime night fax mobile
=item unsuspend
Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
-and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
-on success or a list of errors.
+and L<FS::cust_pkg>) for this customer, except those on hold.
+
+Returns a list: an empty list on success or a list of errors.
=cut
sub unsuspend {
my $self = shift;
- grep { $_->unsuspend } $self->suspended_pkgs;
+ grep { ($_->get('setup')) && $_->unsuspend } $self->suspended_pkgs;
+}
+
+=item release_hold
+
+Unsuspends all suspended packages in the on-hold state (those without setup
+dates) for this customer.
+
+=cut
+
+sub release_hold {
+ my $self = shift;
+ grep { (!$_->setup) && $_->unsuspend } $self->suspended_pkgs;
}
=item suspend
=item cancel [ OPTION => VALUE ... ]
Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
+The cancellation time will be now.
-Available options are:
+=back
+
+Always returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cancel {
+ my $self = shift;
+ my %opt = @_;
+ warn "$me cancel called on customer ". $self->custnum. " with options ".
+ join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
+ if $DEBUG;
+ my @pkgs = $self->ncancelled_pkgs;
+
+ $self->cancel_pkgs( %opt, 'cust_pkg' => \@pkgs );
+}
+
+=item cancel_pkgs OPTIONS
+
+Cancels a specified list of packages. OPTIONS can include:
=over 4
+=item cust_pkg - an arrayref of the packages. Required.
+
+=item time - the cancellation time, used to calculate final bills and
+unused-time credits if any. Will be passed through to the bill() and
+FS::cust_pkg::cancel() methods.
+
=item quiet - can be set true to supress email cancellation notices.
-=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+=item reason - can be set to a cancellation reason (see L<FS:reason>), either a
+reasonnum of an existing reason, or passing a hashref will create a new reason.
+The hashref should have the following keys:
+typenum - Reason type (see L<FS::reason_type>)
+reason - Text of the new reason.
+
+=item cust_pkg_reason - can be an arrayref of L<FS::cust_pkg_reason> objects
+for the individual packages, parallel to the C<cust_pkg> argument. The
+reason and reason_otaker arguments will be taken from those objects.
=item ban - can be set true to ban this customer's credit card or ACH information, if present.
=item nobill - can be set true to skip billing if it might otherwise be done.
-=back
-
-Always returns a list: an empty list on success or a list of errors.
-
=cut
-# nb that dates are not specified as valid options to this method
-
-sub cancel {
+sub cancel_pkgs {
my( $self, %opt ) = @_;
- warn "$me cancel called on customer ". $self->custnum. " with options ".
- join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
- if $DEBUG;
+ # we're going to cancel services, which is not reversible
+ # but on 3.x, don't strictly enforce this
+ warn "cancel_pkgs should not be run inside a transaction"
+ if $FS::UID::AutoCommit == 0;
+
+ local $FS::UID::AutoCommit = 0;
return ( 'access denied' )
unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
my $ban = new FS::banned_pay $self->_new_banned_pay_hashref;
my $error = $ban->insert;
- return ( $error ) if $error;
+ if ($error) {
+ dbh->rollback;
+ return ( $error );
+ }
}
- my @pkgs = $self->ncancelled_pkgs;
+ my @pkgs = @{ delete $opt{'cust_pkg'} };
+ my $cancel_time = $opt{'time'} || time;
+ # bill all packages first, so we don't lose usage, service counts for
+ # bulk billing, etc.
if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
$opt{nobill} = 1;
- my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 );
- warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
- if $error;
+ my $error = $self->bill( 'pkg_list' => [ @pkgs ],
+ 'cancel' => 1,
+ 'time' => $cancel_time );
+ if ($error) {
+ warn "Error billing during cancel, custnum ". $self->custnum. ": $error";
+ dbh->rollback;
+ return ( "Error billing during cancellation: $error" );
+ }
+ }
+ dbh->commit;
+
+ $FS::UID::AutoCommit = 1;
+ my @errors;
+ # now cancel all services, the same way we would for individual packages.
+ # if any of them fail, cancel the rest anyway.
+ my @cust_svc = map { $_->cust_svc } @pkgs;
+ my @sorted_cust_svc =
+ map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; } @cust_svc
+ ;
+ warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ".
+ $self->custnum."\n"
+ if $DEBUG;
+ foreach my $cust_svc (@sorted_cust_svc) {
+ my $part_svc = $cust_svc->part_svc;
+ next if ( defined($part_svc) and $part_svc->preserve );
+ my $error = $cust_svc->cancel; # immediate cancel, no date option
+ push @errors, $error if $error;
+ }
+ if (@errors) {
+ return @errors;
}
- warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
- scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
+ warn "$me cancelling ". scalar(@pkgs) ." package(s) for customer ".
+ $self->custnum. "\n"
if $DEBUG;
- grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
+ my @cprs;
+ if ($opt{'cust_pkg_reason'}) {
+ @cprs = @{ delete $opt{'cust_pkg_reason'} };
+ }
+ my $null_reason;
+ foreach (@pkgs) {
+ my %lopt = %opt;
+ if (@cprs) {
+ my $cpr = shift @cprs;
+ if ( $cpr ) {
+ $lopt{'reason'} = $cpr->reasonnum;
+ $lopt{'reason_otaker'} = $cpr->otaker;
+ } else {
+ warn "no reason found when canceling package ".$_->pkgnum."\n";
+ $lopt{'reason'} = '';
+ }
+ }
+ my $error = $_->cancel(%lopt);
+ push @errors, 'pkgnum '.$_->pkgnum.': '.$error if $error;
+ }
+
+ return @errors;
}
sub _banned_pay_hashref {
sub notes {
my($self,$orderby_classnum) = (shift,shift);
- my $orderby = "_DATE DESC";
- $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
+ my $orderby = "sticky DESC, _date DESC";
+ $orderby = "classnum ASC, $orderby" if $orderby_classnum;
qsearch( 'cust_main_note',
{ 'custnum' => $self->custnum },
'',
}else{
$amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
}
- return '' unless $amount > 0;
+ if ($amount <= 0) {
+ warn(sprintf("Customer balance %.2f - in transit amount %.2f is <= 0.\n",
+ $self->balance,
+ $self->in_transit_payments
+ ));
+ return;
+ }
my $invnum = delete $options{invnum};
my $payby = $options{payby} || $self->payby; #still dubious
L<Date::Parse> for conversion functions. The empty string can be passed
to disable that time constraint completely.
-Available options are:
+Accepts the same options as L<balance_date_sql>:
=over 4
set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+=item cutoff
+
+An absolute cutoff time. Payments, credits, and refunds I<applied> after this
+time will be ignored. Note that START_TIME and END_TIME only limit the date
+range for invoices and I<unapplied> payments, credits, and refunds.
+
=back
=cut
foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
'batchnum' => $pay_batch->batchnum,
'custnum' => $self->custnum,
+ 'status' => '',
} ) ) {
$in_transit_payments += $cust_pay_batch->amount;
}
join(', ', $self->invoicing_list_emailonly);
}
+=item contact_list [ CLASSNUM, ... ]
+
+Returns a list of contacts (L<FS::contact> objects) for the customer. If
+a list of contact classnums is given, returns only contacts in those
+classes. If '0' is given, also returns contacts with no class.
+
+If no arguments are given, returns all contacts for the customer.
+
+=cut
+
+sub contact_list {
+ my $self = shift;
+ my $search = {
+ table => 'contact',
+ select => 'contact.*',
+ extra_sql => ' WHERE contact.custnum = '.$self->custnum,
+ };
+
+ my @orwhere;
+ my @classnums;
+ foreach (@_) {
+ if ( $_ eq '0' ) {
+ push @orwhere, 'contact.classnum is null';
+ } elsif ( /^\d+$/ ) {
+ push @classnums, $_;
+ } else {
+ die "bad classnum argument '$_'";
+ }
+ }
+
+ if (@classnums) {
+ push @orwhere, 'contact.classnum IN ('.join(',', @classnums).')';
+ }
+ if (@orwhere) {
+ $search->{extra_sql} .= ' AND (' .
+ join(' OR ', map "( $_ )", @orwhere) .
+ ')';
+ }
+
+ qsearch($search);
+}
+
+=item contact_list_email [ CLASSNUM, ... ]
+
+Same as L</contact_list>, but returns email destinations instead of contact
+objects. Also accepts 'invoice' as an argument, in which case this will also
+return the invoice email address if any.
+
+=cut
+
+sub contact_list_email {
+ my $self = shift;
+ my @classnums;
+ my $and_invoice;
+ foreach (@_) {
+ if (/^invoice$/) {
+ $and_invoice = 1;
+ } else {
+ push @classnums, $_;
+ }
+ }
+ my %emails;
+ # if the only argument passed was 'invoice' then no classnums are
+ # intended, so skip this.
+ if ( @classnums ) {
+ my @contacts = $self->contact_list(@classnums);
+ foreach my $contact (@contacts) {
+ foreach my $contact_email ($contact->contact_email) {
+ # unlike on 4.x, we have a separate list of invoice email
+ # destinations.
+ # make sure they're not redundant with contact emails
+ $emails{ $contact_email->emailaddress } =
+ Email::Address->new( $contact->firstlast,
+ $contact_email->emailaddress
+ )->format;
+ }
+ }
+ }
+ if ( $and_invoice ) {
+ foreach my $email ($self->invoicing_list_emailonly) {
+ $emails{ $email } ||=
+ Email::Address->new( $self->name_short, $email )->format;
+ }
+ }
+ values %emails;
+}
+
=item referral_custnum_cust_main
Returns the customer who referred this customer (or the empty string, if
=cut
+#super false laziness w/quotation::charge
sub charge {
my $self = shift;
- my ( $amount, $quantity, $start_date, $classnum );
+ my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
my ( $pkg, $comment, $additional );
my ( $setuptax, $taxclass ); #internal taxes
my ( $taxproduct, $override ); #vendor (CCH) taxes
my $no_auto = '';
+ my $separate_bill = '';
my $cust_pkg_ref = '';
my ( $bill_now, $invoice_terms ) = ( 0, '' );
my $locationnum;
if ( ref( $_[0] ) ) {
$amount = $_[0]->{amount};
+ $setup_cost = $_[0]->{setup_cost};
$quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
$start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
$no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
$bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
$invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
$locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
- } else {
+ $separate_bill = $_[0]->{separate_bill} || '';
+ } else { # yuck
$amount = shift;
+ $setup_cost = '';
$quantity = 1;
$start_date = '';
$pkg = @_ ? shift : 'One-time charge';
'setuptax' => $setuptax,
'taxclass' => $taxclass,
'taxproductnum' => $taxproduct,
+ 'setup_cost' => $setup_cost,
} );
my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
'quantity' => $quantity,
'start_date' => $start_date,
'no_auto' => $no_auto,
+ 'separate_bill' => $separate_bill,
'locationnum'=> $locationnum,
} );
);
}
+=item cust_credit_void
+
+Returns all voided credits (see L<FS::cust_credit_void>) for this customer.
+
+=cut
+
+sub cust_credit_void {
+ my $self = shift;
+ map { $_ }
+ sort { $a->_date <=> $b->_date }
+ qsearch( 'cust_credit_void', { 'custnum' => $self->custnum } )
+}
+
=item cust_pay
Returns all the payments (see L<FS::cust_pay>) for this customer.
sub cust_pay {
my $self = shift;
- return $self->num_cust_pay unless wantarray;
- sort { $a->_date <=> $b->_date }
- qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
+ my $opt = ref($_[0]) ? shift : { @_ };
+
+ return $self->num_cust_pay unless wantarray || keys %$opt;
+
+ $opt->{'table'} = 'cust_pay';
+ $opt->{'hashref'}{'custnum'} = $self->custnum;
+
+ map { $_ } #behavior of sort undefined in scalar context
+ sort { $a->_date <=> $b->_date }
+ qsearch($opt);
+
}
=item num_cust_pay
$sth->fetchrow_arrayref->[0];
}
+=item unapplied_cust_pay
+
+Returns all the unapplied payments (see L<FS::cust_pay>) for this customer.
+
+=cut
+
+sub unapplied_cust_pay {
+ my $self = shift;
+
+ $self->cust_pay(
+ 'extra_sql' => ' AND '. FS::cust_pay->unapplied_sql. ' > 0',
+ #@_
+ );
+
+}
+
=item cust_pay_pkgnum
Returns all the payments (see L<FS::cust_pay>) for this customer's specific
sub display_custnum {
my $self = shift;
+ return $self->agent_custid
+ if $default_agent_custid && $self->agent_custid;
+
my $prefix = $conf->config('cust_main-custnum-display_prefix', $self->agentnum) || '';
- if ( my $special = $conf->config('cust_main-custnum-display_special') ) {
- if ( $special eq 'CoStAg' ) {
- $prefix = uc( join('',
- $self->country,
- ($self->state =~ /^(..)/),
- $prefix || ($self->agent->agent =~ /^(..)/)
- ) );
- }
- elsif ( $special eq 'CoStCl' ) {
- $prefix = uc( join('',
- $self->country,
- ($self->state =~ /^(..)/),
- ($self->classnum ? $self->cust_class->classname =~ /^(..)/ : '__')
- ) );
- }
- # add any others here if needed
- }
- my $length = $conf->config('cust_main-custnum-display_length');
- if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
- return $self->agent_custid;
- } elsif ( $prefix ) {
- $length = 8 if !defined($length);
+ if ( $prefix ) {
return $prefix .
- sprintf('%0'.$length.'d', $self->custnum)
- } elsif ( $length ) {
- return sprintf('%0'.$length.'d', $self->custnum);
+ sprintf('%0'.($custnum_display_length||8).'d', $self->custnum)
+ } elsif ( $custnum_display_length ) {
+ return sprintf('%0'.$custnum_display_length.'d', $self->custnum);
} else {
return $self->custnum;
}
$contact->get('first') . ' '. $contact->get('last');
}
-#XXX this doesn't work in 3.x+
-#=item country_full
-#
-#Returns this customer's full country name
-#
-#=cut
-#
-#sub country_full {
-# my $self = shift;
-# code2country($self->country);
-#}
+sub bill_country_full {
+ my $self = shift;
+ $self->bill_location->country_full;
+}
+
+sub ship_country_full {
+ my $self = shift;
+ $self->ship_location->country_full;
+}
=item county_state_county [ PREFIX ]
sub cust_status {
my $self = shift;
+ return $self->hashref->{cust_status} if $self->hashref->{cust_status};
for my $status ( FS::cust_main->statuses() ) {
my $method = $status.'_sql';
my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
$sth->execute( ($self->custnum) x $numnum )
or die "Error executing 'SELECT $sql': ". $sth->errstr;
- return $status if $sth->fetchrow_arrayref->[0];
+ if ( $sth->fetchrow_arrayref->[0] ) {
+ $self->hashref->{cust_status} = $status;
+ return $status;
+ }
}
}
+=item is_status_delay_cancel
+
+Returns true if customer status is 'suspended'
+and all suspended cust_pkg return true for
+cust_pkg->is_status_delay_cancel.
+
+This is not a real status, this only meant for hacking display
+values, because otherwise treating the customer as suspended is
+really the whole point of the delay_cancel option.
+
+=cut
+
+sub is_status_delay_cancel {
+ my ($self) = @_;
+ return 0 unless $self->status eq 'suspended';
+ foreach my $cust_pkg ($self->ncancelled_pkgs) {
+ return 0 unless $cust_pkg->is_status_delay_cancel;
+ }
+ return 1;
+}
+
=item ucfirst_cust_status
=item ucfirst_status
__PACKAGE__->statuscolors->{$self->cust_status};
}
-=item tickets
+=item tickets [ STATUS ]
Returns an array of hashes representing the customer's RT tickets.
+An optional status (or arrayref or hashref of statuses) may be specified.
+
=cut
sub tickets {
my $self = shift;
+ my $status = ( @_ && $_[0] ) ? shift : '';
my $num = $conf->config('cust_main-max_tickets') || 10;
my @tickets = ();
if ( $conf->config('ticket_system') ) {
unless ( $conf->config('ticket_system-custom_priority_field') ) {
- @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+ @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum,
+ $num,
+ undef,
+ $status,
+ )
+ };
} else {
@{ FS::TicketSystem->customer_tickets( $self->custnum,
$num - scalar(@tickets),
$priority,
+ $status,
)
};
}
(@tickets);
}
+=item appointments [ STATUS ]
+
+Returns an array of hashes representing the customer's RT tickets which
+are appointments.
+
+=cut
+
+sub appointments {
+ my $self = shift;
+ my $status = ( @_ && $_[0] ) ? shift : '';
+
+ return () unless $conf->config('ticket_system');
+
+ my $queueid = $conf->config('ticket_system-appointment-queueid');
+
+ @{ FS::TicketSystem->customer_tickets( $self->custnum,
+ 99,
+ undef,
+ $status,
+ $queueid,
+ )
+ };
+}
+
# Return services representing svc_accts in customer support packages
sub support_services {
my $self = shift;
}
+=item payment_history
+
+Returns an array of hashrefs standardizing information from cust_bill, cust_pay,
+cust_credit and cust_refund objects. Each hashref has the following fields:
+
+I<type> - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous'
+
+I<date> - value of _date field, unix timestamp
+
+I<date_pretty> - user-friendly date
+
+I<description> - user-friendly description of item
+
+I<amount> - impact of item on user's balance
+(positive for Invoice/Refund/Line item, negative for Payment/Credit.)
+Not to be confused with the native 'amount' field in cust_credit, see below.
+
+I<amount_pretty> - includes money char
+
+I<balance> - customer balance, chronologically as of this item
+
+I<balance_pretty> - includes money char
+
+I<charged> - amount charged for cust_bill (Invoice or Line item) records, undef for other types
+
+I<paid> - amount paid for cust_pay records, undef for other types
+
+I<credit> - amount credited for cust_credit records, undef for other types.
+Literally the 'amount' field from cust_credit, renamed here to avoid confusion.
+
+I<refund> - amount refunded for cust_refund records, undef for other types
+
+The four table-specific keys always have positive values, whether they reflect charges or payments.
+
+The following options may be passed to this method:
+
+I<line_items> - if true, returns charges ('Line item') rather than invoices
+
+I<start_date> - unix timestamp, only include records on or after.
+If specified, an item of type 'Previous' will also be included.
+It does not have table-specific fields.
+
+I<end_date> - unix timestamp, only include records before
+
+I<reverse_sort> - order from newest to oldest (default is oldest to newest)
+
+I<conf> - optional already-loaded FS::Conf object.
+
+=cut
+
+# Caution: this gets used by FS::ClientAPI::MyAccount::billing_history,
+# and also for sending customer statements, which should both be kept customer-friendly.
+# If you add anything that shouldn't be passed on through the API or exposed
+# to customers, add a new option to include it, don't include it by default
+sub payment_history {
+ my $self = shift;
+ my $opt = ref($_[0]) ? $_[0] : { @_ };
+
+ my $conf = $$opt{'conf'} || new FS::Conf;
+ my $money_char = $conf->config("money_char") || '$',
+
+ #first load entire history,
+ #need previous to calculate previous balance
+ #loading after end_date shouldn't hurt too much?
+ my @history = ();
+ if ( $$opt{'line_items'} ) {
+
+ foreach my $cust_bill ( $self->cust_bill ) {
+
+ push @history, {
+ 'type' => 'Line item',
+ 'description' => $_->desc( $self->locale ).
+ ( $_->sdate && $_->edate
+ ? ' '. time2str('%d-%b-%Y', $_->sdate).
+ ' To '. time2str('%d-%b-%Y', $_->edate)
+ : ''
+ ),
+ 'amount' => sprintf('%.2f', $_->setup + $_->recur ),
+ 'charged' => sprintf('%.2f', $_->setup + $_->recur ),
+ 'date' => $cust_bill->_date,
+ 'date_pretty' => $self->time2str_local('short', $cust_bill->_date ),
+ }
+ foreach $cust_bill->cust_bill_pkg;
+
+ }
+
+ } else {
+
+ push @history, {
+ 'type' => 'Invoice',
+ 'description' => 'Invoice #'. $_->display_invnum,
+ 'amount' => sprintf('%.2f', $_->charged ),
+ 'charged' => sprintf('%.2f', $_->charged ),
+ 'date' => $_->_date,
+ 'date_pretty' => $self->time2str_local('short', $_->_date ),
+ }
+ foreach $self->cust_bill;
+
+ }
+
+ push @history, {
+ 'type' => 'Payment',
+ 'description' => 'Payment', #XXX type
+ 'amount' => sprintf('%.2f', 0 - $_->paid ),
+ 'paid' => sprintf('%.2f', $_->paid ),
+ 'date' => $_->_date,
+ 'date_pretty' => $self->time2str_local('short', $_->_date ),
+ }
+ foreach $self->cust_pay;
+
+ push @history, {
+ 'type' => 'Credit',
+ 'description' => 'Credit', #more info?
+ 'amount' => sprintf('%.2f', 0 -$_->amount ),
+ 'credit' => sprintf('%.2f', $_->amount ),
+ 'date' => $_->_date,
+ 'date_pretty' => $self->time2str_local('short', $_->_date ),
+ }
+ foreach $self->cust_credit;
+
+ push @history, {
+ 'type' => 'Refund',
+ 'description' => 'Refund', #more info? type, like payment?
+ 'amount' => $_->refund,
+ 'refund' => $_->refund,
+ 'date' => $_->_date,
+ 'date_pretty' => $self->time2str_local('short', $_->_date ),
+ }
+ foreach $self->cust_refund;
+
+ #put it all in chronological order
+ @history = sort { $a->{'date'} <=> $b->{'date'} } @history;
+
+ #calculate balance, filter items outside date range
+ my $previous = 0;
+ my $balance = 0;
+ my @out = ();
+ foreach my $item (@history) {
+ last if $$opt{'end_date'} && ($$item{'date'} >= $$opt{'end_date'});
+ $balance += $$item{'amount'};
+ if ($$opt{'start_date'} && ($$item{'date'} < $$opt{'start_date'})) {
+ $previous += $$item{'amount'};
+ next;
+ }
+ $$item{'balance'} = sprintf("%.2f",$balance);
+ foreach my $key ( qw(amount balance) ) {
+ $$item{$key.'_pretty'} = money_pretty($$item{$key});
+ }
+ push(@out,$item);
+ }
+
+ # start with previous balance, if there was one
+ if ($previous) {
+ my $item = {
+ 'type' => 'Previous',
+ 'description' => 'Previous balance',
+ 'amount' => sprintf("%.2f",$previous),
+ 'balance' => sprintf("%.2f",$previous),
+ 'date' => $$opt{'start_date'},
+ 'date_pretty' => $self->time2str_local('short', $$opt{'start_date'} ),
+ };
+ #false laziness with above
+ foreach my $key ( qw(amount balance) ) {
+ $$item{$key.'_pretty'} = $$item{$key};
+ $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+ }
+ unshift(@out,$item);
+ }
+
+ @out = reverse @history if $$opt{'reverse_sort'};
+
+ return @out;
+}
+
=back
=head1 CLASS METHODS
=cut
sub uncancelled_sql { uncancel_sql(@_); }
-sub uncancel_sql { "
- ( 0 < ( $select_count_pkgs
- AND ( cust_pkg.cancel IS NULL
- OR cust_pkg.cancel = 0
- )
- )
- OR 0 = ( $select_count_pkgs )
- )
-"; }
+sub uncancel_sql {
+ my $self = shift;
+ "( NOT (".$self->cancelled_sql.") )"; #sensitive to cust_main-status_module
+}
=item balance_sql
=over 4
-=item batch_charge
-
-=cut
-
-sub batch_charge {
- my $param = shift;
- #warn join('-',keys %$param);
- my $fh = $param->{filehandle};
- my $agentnum = $param->{agentnum};
- my $format = $param->{format};
-
- my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
-
- my @fields;
- if ( $format eq 'simple' ) {
- @fields = qw( custnum agent_custid amount pkg );
- } else {
- die "unknown format $format";
- }
-
- 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;
- }
-
- if ( $row{custnum} && $row{agent_custid} ) {
- dbh->rollback if $oldAutoCommit;
- return "can't specify custnum with agent_custid $row{agent_custid}";
- }
-
- my %hash = ();
- if ( $row{agent_custid} && $agentnum ) {
- %hash = ( 'agent_custid' => $row{agent_custid},
- 'agentnum' => $agentnum,
- );
- }
-
- if ( $row{custnum} ) {
- %hash = ( 'custnum' => $row{custnum} );
- }
-
- unless ( scalar(keys %hash) ) {
- $dbh->rollback if $oldAutoCommit;
- return "can't find customer without custnum or agent_custid and agentnum";
- }
-
- my $cust_main = qsearchs('cust_main', { %hash } );
- unless ( $cust_main ) {
- $dbh->rollback if $oldAutoCommit;
- my $custnum = $row{custnum} || $row{agent_custid};
- return "unknown custnum $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
-
-}
-
=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
Deprecated. Use event notification and message templates
return unless $conf->exists($template);
- my $from = $conf->config('invoice_from', $self->agentnum)
+ my $from = $conf->invoice_from_full($self->agentnum)
if $conf->exists('invoice_from', $self->agentnum);
$from = $options{from} if exists($options{from});
into the template. These values may override values mentioned below
and those from the customer record.
+I<template_text> - if present, ignores TEMPLATE_NAME and uses the provided text
+
The following variables are available in the template instead of or in addition
to the fields of the customer record.
sub generate_letter {
my ($self, $template, %options) = @_;
- return unless $conf->exists($template);
+ warn "Template $template does not exist" && return
+ unless $conf->exists($template) || $options{'template_text'};
+
+ my $template_source = $options{'template_text'}
+ ? [ $options{'template_text'} ]
+ : [ map "$_\n", $conf->config($template) ];
my $letter_template = new Text::Template
( TYPE => 'ARRAY',
- SOURCE => [ map "$_\n", $conf->config($template)],
+ SOURCE => $template_source,
DELIMITERS => [ '[@--', '--@]' ],
)
or die "can't create new Text::Template object: Text::Template::ERROR";
my %opt = @_;
my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
- or die "invalid customer number: " . $opt{custvnum};
+ or die "invalid customer number: " . $opt{custnum};
- my $error = $self->print( $opt{template} );
+ my $error = $self->print( { 'template' => $opt{template} } );
die $error if $error;
}
my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
warn 'bill_and_collect custnum#'. $cust_main->custnum. "\n";#log custnum w/pid
+ #without this errors don't get rolled back
+ $args{'fatal'} = 1; # runs from job queue, will be caught
+
$cust_main->bill_and_collect( %args );
}
$class->_upgrade_otaker(%opts);
+ # turn on encryption as part of regular upgrade, so all new records are immediately encrypted
+ # existing records will be encrypted in queueable_upgrade (below)
+ unless ($conf->exists('encryptionpublickey') || $conf->exists('encryptionprivatekey')) {
+ eval "use FS::Setup";
+ die $@ if $@;
+ FS::Setup::enable_encryption();
+ }
+
+}
+
+sub queueable_upgrade {
+ my $class = shift;
+
+ ### encryption gets turned on in _upgrade_data, above
+
+ eval "use FS::upgrade_journal";
+ die $@ if $@;
+
+ # prior to 2013 (commit f16665c9) payinfo was stored in history if not encrypted,
+ # clear that out before encrypting/tokenizing anything else
+ if (!FS::upgrade_journal->is_done('clear_payinfo_history')) {
+ foreach my $table ('cust_main','cust_pay_pending','cust_pay','cust_pay_void','cust_refund') {
+ my $sql = 'UPDATE h_'.$table.' SET payinfo = NULL WHERE payinfo IS NOT NULL';
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute or die $sth->errstr;
+ }
+ FS::upgrade_journal->set_done('clear_payinfo_history');
+ }
+
+ # fix Tokenized paycardtype and encrypt old records
+ if ( ! FS::upgrade_journal->is_done('paycardtype_Tokenized')
+ || ! FS::upgrade_journal->is_done('encryption_check')
+ )
+ {
+
+ # allow replacement of closed cust_pay/cust_refund records
+ local $FS::payinfo_Mixin::allow_closed_replace = 1;
+
+ # because it looks like nothing's changing
+ local $FS::Record::no_update_diff = 1;
+
+ # commit everything immediately
+ local $FS::UID::AutoCommit = 1;
+
+ # encrypt what's there
+ foreach my $table ('cust_main','cust_pay_pending','cust_pay','cust_pay_void','cust_refund') {
+ my $tclass = 'FS::'.$table;
+ my $lastrecnum = 0;
+ my @recnums = ();
+ while (my $recnum = _upgrade_next_recnum(dbh,$table,\$lastrecnum,\@recnums)) {
+ my $record = $tclass->by_key($recnum);
+ next unless $record; # small chance it's been deleted, that's ok
+ next unless grep { $record->payby eq $_ } @FS::Record::encrypt_payby;
+ # window for possible conflict is practically nonexistant,
+ # but just in case...
+ $record = $record->select_for_update;
+ if (!$record->custnum && $table eq 'cust_pay_pending') {
+ $record->set('custnum_pending',1);
+ }
+ $record->paycardtype('') if $record->paycardtype eq 'Tokenized';
+
+ local($ignore_expired_card) = 1;
+ local($ignore_banned_card) = 1;
+ local($skip_fuzzyfiles) = 1;
+ local($import) = 1;#prevent automatic geocoding (need its own variable?)
+
+ my $error = $record->replace;
+ die "Error replacing $table ".$record->get($record->primary_key).": $error" if $error;
+ }
+ }
+
+ FS::upgrade_journal->set_done('paycardtype_Tokenized');
+ FS::upgrade_journal->set_done('encryption_check') if $conf->exists('encryption');
+ }
+
+}
+
+# not entirely false laziness w/ Billing_Realtime::_token_check_next_recnum
+# cust_payby might get deleted while this runs
+# not a method!
+sub _upgrade_next_recnum {
+ my ($dbh,$table,$lastrecnum,$recnums) = @_;
+ my $recnum = shift @$recnums;
+ return $recnum if $recnum;
+ my $tclass = 'FS::'.$table;
+ my $paycardtypecheck = ($table ne 'cust_pay_pending') ? q( OR paycardtype = 'Tokenized') : '';
+ my $sql = 'SELECT '.$tclass->primary_key.
+ ' FROM '.$table.
+ ' WHERE '.$tclass->primary_key.' > '.$$lastrecnum.
+ " AND payby IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
+ " AND ( length(payinfo) < 80$paycardtypecheck ) ".
+ ' ORDER BY '.$tclass->primary_key.' LIMIT 500';
+ my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ my @recnums;
+ while (my $rec = $sth->fetchrow_hashref) {
+ push @$recnums, $rec->{$tclass->primary_key};
+ }
+ $sth->finish();
+ $$lastrecnum = $$recnums[-1];
+ return shift @$recnums;
}
=back