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::Manip;
use File::Temp; #qw( tempfile );
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;
#$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 {
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";
}
+ 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;
|| $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;
+ }
+
return "Invoicing locale is required"
if $old->locale
&& ! $self->locale
|| $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_flag('invoice_noemail')
|| $self->ut_flag('message_noemail')
|| $self->ut_enum('locale', [ '', FS::Locales->locales ])
+ || $self->ut_flag('invoice_ship_address')
;
foreach (qw(company ship_company)) {
$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 =
$self->SUPER::check;
}
+sub check_payinfo_cardtype {
+ my $self = shift;
+
+ return '' unless $self->payby =~ /^(CARD|DCRD)$/;
+
+ my $payinfo = $self->payinfo;
+ $payinfo =~ s/\D//g;
+
+ return '' if $payinfo =~ /^99\d{14}$/; #token
+
+ my %bop_card_types = map { $_=>1 } values %{ card_types() };
+ my $cardtype = cardtype($payinfo);
+
+ 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
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;
}
=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,
} );
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;
- code2country($self->bill_location->country);
+ $self->bill_location->country_full;
}
sub ship_country_full {
my $self = shift;
- code2country($self->ship_location->country);
+ $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
__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
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 $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 );
}