X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=73177862d6087c9af4da563d62a89cdddbf6141f;hb=f13afe5e228a220311557e1ca6dacbf847c26baf;hp=1f402fca4875dec86e7d8e939ee6bde835b2b817;hpb=17544eafba683f48cdc64fef09745d17be9b088a;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 1f402fca4..46189586b 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,73 +1,46 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $conf $money_char ); -use vars qw( $lpr $invoice_from $smtpmachine ); -use vars qw( $processor ); -use vars qw( $xaction $E_NoErr ); -use vars qw( $bop_processor $bop_login $bop_password $bop_action @bop_options ); +use vars qw( @ISA $DEBUG $me $conf $money_char ); use vars qw( $invoice_lines @buf ); #yuck +use Fcntl qw(:flock); #for spool_csv +use List::Util qw(min max); use Date::Format; -use Mail::Internet; -use Mail::Header; -use Text::Template; -use FS::Record qw( qsearch qsearchs ); +use Text::Template 1.20; +use File::Temp 0.14; +use String::ShellQuote; +use HTML::Entities; +use Locale::Country; +use FS::UID qw( datasrc ); +use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print ); +use FS::Record qw( qsearch qsearchs dbh ); +use FS::cust_main_Mixin; use FS::cust_main; use FS::cust_bill_pkg; use FS::cust_credit; use FS::cust_pay; use FS::cust_pkg; use FS::cust_credit_bill; +use FS::pay_batch; use FS::cust_pay_batch; use FS::cust_bill_event; +use FS::cust_event; +use FS::part_pkg; +use FS::cust_bill_pay; +use FS::cust_bill_pay_batch; +use FS::part_bill_event; +use FS::payby; -@ISA = qw( FS::Record ); +@ISA = qw( FS::cust_main_Mixin FS::Record ); -#ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_bill'} = sub { +$DEBUG = 0; +$me = '[FS::cust_bill]'; +#ask FS::UID to run this stuff for us later +FS::UID->install_callback( sub { $conf = new FS::Conf; - $money_char = $conf->config('money_char') || '$'; - - $lpr = $conf->config('lpr'); - $invoice_from = $conf->config('invoice_from'); - $smtpmachine = $conf->config('smtpmachine'); - - if ( $conf->exists('cybercash3.2') ) { - require CCMckLib3_2; - #qw($MCKversion %Config InitConfig CCError CCDebug CCDebug2); - require CCMckDirectLib3_2; - #qw(SendCC2_1Server); - require CCMckErrno3_2; - #qw(MCKGetErrorMessage $E_NoErr); - import CCMckErrno3_2 qw($E_NoErr); - - my $merchant_conf; - ($merchant_conf,$xaction)= $conf->config('cybercash3.2'); - my $status = &CCMckLib3_2::InitConfig($merchant_conf); - if ( $status != $E_NoErr ) { - warn "CCMckLib3_2::InitConfig error:\n"; - foreach my $key (keys %CCMckLib3_2::Config) { - warn " $key => $CCMckLib3_2::Config{$key}\n" - } - my($errmsg) = &CCMckErrno3_2::MCKGetErrorMessage($status); - die "CCMckLib3_2::InitConfig fatal error: $errmsg\n"; - } - $processor='cybercash3.2'; - } elsif ( $conf->exists('business-onlinepayment') ) { - ( $bop_processor, - $bop_login, - $bop_password, - $bop_action, - @bop_options - ) = $conf->config('business-onlinepayment'); - $bop_action ||= 'normal authorization'; - eval "use Business::OnlinePayment"; - $processor="Business::OnlinePayment::$bop_processor"; - } - -}; +} ); =head1 NAME @@ -139,6 +112,13 @@ Invoices are normally created by calling the bill method of a customer object sub table { 'cust_bill'; } +sub cust_linked { $_[0]->cust_main_custnum; } +sub cust_unlinked_msg { + my $self = shift; + "WARNING: can't find cust_main.custnum ". $self->custnum. + ' (cust_bill.invnum '. $self->invnum. ')'; +} + =item insert Adds this invoice to the database ("Posts" the invoice). If there is an error, @@ -146,8 +126,14 @@ returns the error, otherwise returns false. =item delete -Currently unimplemented. I don't remove invoices because there would then be -no record you ever posted this invoice (which is bad, no?) +This method now works but you probably shouldn't use it. Instead, apply a +credit against the invoice. + +Using this method to delete invoices outright is really, really bad. There +would be no record you ever posted this invoice, and there are no check to +make sure charged = 0 or that there are no associated cust_bill_pkg records. + +Really, don't use it. =cut @@ -167,14 +153,20 @@ collect method of a customer object (see L). =cut -sub replace { +#replace can be inherited from Record.pm + +# replace_check is now the preferred way to #implement replace data checks +# (so $object->replace() works without an argument) + +sub replace_check { my( $new, $old ) = ( shift, shift ); return "Can't change custnum!" unless $old->custnum == $new->custnum; #return "Can't change _date!" unless $old->_date eq $new->_date; return "Can't change _date!" unless $old->_date == $new->_date; - return "Can't change charged!" unless $old->charged == $new->charged; + return "Can't change charged!" unless $old->charged == $new->charged + || $old->charged == 0; - $new->SUPER::replace($old); + ''; } =item check @@ -205,7 +197,7 @@ sub check { $self->printed(0) if $self->printed eq ''; - ''; #no error + $self->SUPER::check; } =item previous @@ -237,10 +229,50 @@ sub cust_bill_pkg { qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } ); } +=item cust_pkg + +Returns the packages (see L) corresponding to the line items for +this invoice. + +=cut + +sub cust_pkg { + my $self = shift; + my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg; + my %saw = (); + grep { ! $saw{$_->pkgnum}++ } @cust_pkg; +} + +=item open_cust_bill_pkg + +Returns the open line items for this invoice. + +Note that cust_bill_pkg with both setup and recur fees are returned as two +separate line items, each with only one fee. + +=cut + +# modeled after cust_main::open_cust_bill +sub open_cust_bill_pkg { + my $self = shift; + + # grep { $_->owed > 0 } $self->cust_bill_pkg + + my %other = ( 'recur' => 'setup', + 'setup' => 'recur', ); + my @open = (); + foreach my $field ( qw( recur setup )) { + push @open, map { $_->set( $other{$field}, 0 ); $_; } + grep { $_->owed($field) > 0 } + $self->cust_bill_pkg; + } + + @open; +} + =item cust_bill_event -Returns the completed invoice events (see L) for this -invoice. +Returns the completed invoice events (deprecated, old-style events - see L) for this invoice. =cut @@ -249,6 +281,54 @@ sub cust_bill_event { qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } ); } +=item num_cust_bill_event + +Returns the number of completed invoice events (deprecated, old-style events - see L) for this invoice. + +=cut + +sub num_cust_bill_event { + my $self = shift; + my $sql = + "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?"; + my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; + $sth->execute($self->invnum) or die $sth->errstr. " executing $sql"; + $sth->fetchrow_arrayref->[0]; +} + +=item cust_event + +Returns the new-style customer billing events (see L) for this invoice. + +=cut + +#false laziness w/cust_pkg.pm +sub cust_event { + my $self = shift; + qsearch({ + 'table' => 'cust_event', + 'addl_from' => 'JOIN part_event USING ( eventpart )', + 'hashref' => { 'tablenum' => $self->invnum }, + 'extra_sql' => " AND eventtable = 'cust_bill' ", + }); +} + +=item num_cust_event + +Returns the number of new-style customer billing events (see L) for this invoice. + +=cut + +#false laziness w/cust_pkg.pm +sub num_cust_event { + my $self = shift; + my $sql = + "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ". + " WHERE tablenum = ? AND eventtable = 'cust_bill'"; + my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; + $sth->execute($self->invnum) or die $sth->errstr. " executing $sql"; + $sth->fetchrow_arrayref->[0]; +} =item cust_main @@ -261,6 +341,25 @@ sub cust_main { qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); } +=item cust_suspend_if_balance_over AMOUNT + +Suspends the customer associated with this invoice if the total amount owed on +this invoice and all older invoices is greater than the specified amount. + +Returns a list: an empty list on success or a list of errors. + +=cut + +sub cust_suspend_if_balance_over { + my( $self, $amount ) = ( shift, shift ); + my $cust_main = $self->cust_main; + if ( $cust_main->total_owed_date($self->_date) < $amount ) { + return (); + } else { + $cust_main->suspend(@_); + } +} + =item cust_credit Depreciated. See the cust_credited method. @@ -360,557 +459,2559 @@ sub owed { $balance; } -=item send - -Sends this invoice to the destinations configured for this customer: send -emails or print. See L. +=item apply_payments_and_credits =cut -sub send { - my($self,$template) = @_; +sub apply_payments_and_credits { + my $self = shift; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + $self->select_for_update; #mutex + + my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay; + my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; + + while ( $self->owed > 0 and ( @payments || @credits ) ) { + + my $app = ''; + if ( @payments && @credits ) { + + #decide which goes first by weight of top (unapplied) line item + + my @open_lineitems = $self->open_cust_bill_pkg; + + my $max_pay_weight = + max( map { $_->part_pkg->pay_weight || 0 } + grep { $_ } + map { $_->cust_pkg } + @open_lineitems + ); + my $max_credit_weight = + max( map { $_->part_pkg->credit_weight || 0 } + grep { $_ } + map { $_->cust_pkg } + @open_lineitems + ); + + #if both are the same... payments first? it has to be something + if ( $max_pay_weight >= $max_credit_weight ) { + $app = 'pay'; + } else { + $app = 'credit'; + } + + } elsif ( @payments ) { + $app = 'pay'; + } elsif ( @credits ) { + $app = 'credit'; + } else { + die "guru meditation #12 and 35"; + } + + if ( $app eq 'pay' ) { + + my $payment = shift @payments; + + $app = new FS::cust_bill_pay { + 'paynum' => $payment->paynum, + 'amount' => sprintf('%.2f', min( $payment->unapplied, $self->owed ) ), + }; + + } elsif ( $app eq 'credit' ) { + + my $credit = shift @credits; + + $app = new FS::cust_credit_bill { + 'crednum' => $credit->crednum, + 'amount' => sprintf('%.2f', min( $credit->credited, $self->owed ) ), + }; + + } else { + die "guru meditation #12 and 35"; + } + + $app->invnum( $self->invnum ); + + my $error = $app->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting ". $app->table. " record: $error"; + } + die $error if $error; - #my @print_text = $cust_bill->print_text; #( date ) - my @invoicing_list = $self->cust_main->invoicing_list; - if ( grep { $_ ne 'POST' } @invoicing_list ) { #email invoice - #false laziness w/FS::cust_pay::delete & fs_signup_server - #$ENV{SMTPHOSTS} = $smtpmachine; - $ENV{MAILADDRESS} = $invoice_from; - my $header = new Mail::Header ( [ - "From: $invoice_from", - "To: ". join(', ', grep { $_ ne 'POST' } @invoicing_list ), - "Sender: $invoice_from", - "Reply-To: $invoice_from", - "Date: ". time2str("%a, %d %b %Y %X %z", time), - "Subject: Invoice", - ] ); - my $message = new Mail::Internet ( - 'Header' => $header, - 'Body' => [ $self->print_text('', $template) ], #( date) - ); - $!=0; - $message->smtpsend( Host => $smtpmachine ) - or $message->smtpsend( Host => $smtpmachine, Debug => 1 ) - or return "(customer # ". $self->custnum. ") can't send invoice email". - " to ". join(', ', grep { $_ ne 'POST' } @invoicing_list ). - " via server $smtpmachine with SMTP: $!"; - - #} elsif ( grep { $_ eq 'POST' } @invoicing_list ) { - } elsif ( ! @invoicing_list || grep { $_ eq 'POST' } @invoicing_list ) { - open(LPR, "|$lpr") - or return "Can't open pipe to $lpr: $!"; - print LPR $self->print_text; #( date ) - close LPR - or return $! ? "Error closing $lpr: $!" - : "Exit status $? from $lpr"; } - ''; + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error } -=item comp +=item generate_email PARAMHASH -Pays this invoice with a compliemntary payment. If there is an error, -returns the error, otherwise returns false. +PARAMHASH can contain the following: -=cut +=over 4 -sub comp { - my $self = shift; - my $cust_pay = new FS::cust_pay ( { - 'invnum' => $self->invnum, - 'paid' => $self->owed, - '_date' => '', - 'payby' => 'COMP', - 'payinfo' => $self->cust_main->payinfo, - 'paybatch' => '', - } ); - $cust_pay->insert; -} +=item from => sender address, required -=item realtime_card +=item tempate => alternate template name, optional + +=item print_text => text attachment arrayref, optional + +=item subject => email subject, optional + +=back -Attempts to pay this invoice with a Business::OnlinePayment realtime gateway. -See http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment -for supproted processors. +Returns an argument list to be passed to L. =cut -sub realtime_card { +use MIME::Entity; + +sub generate_email { + my $self = shift; - my $cust_main = $self->cust_main; - my $amount = $self->owed; - - unless ( $processor =~ /^Business::OnlinePayment::(.*)$/ ) { - return "Real-time card processing not enabled (processor $processor)"; - } - my $bop_processor = $1; #hmm? - - my $address = $cust_main->address1; - $address .= ", ". $cust_main->address2 if $cust_main->address2; - - #fix exp. date - #$cust_main->paydate =~ /^(\d+)\/\d*(\d{2})$/; - $cust_main->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; - my $exp = "$2/$1"; - - my($payname, $payfirst, $paylast); - if ( $cust_main->payname ) { - $payname = $cust_main->payname; - $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)$/ - or do { - #$dbh->rollback if $oldAutoCommit; - return "Illegal payname $payname"; - }; - ($payfirst, $paylast) = ($1, $2); + my %args = @_; + + my $me = '[FS::cust_bill::generate_email]'; + + my %return = ( + 'from' => $args{'from'}, + 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), + ); + + if (ref($args{'to'}) eq 'ARRAY') { + $return{'to'} = $args{'to'}; } else { - $payfirst = $cust_main->getfield('first'); - $paylast = $cust_main->getfield('first'); - $payname = "$payfirst $paylast"; + $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ } + $self->cust_main->invoicing_list + ]; } - my @invoicing_list = grep { $_ ne 'POST' } $cust_main->invoicing_list; - if ( $conf->exists('emailinvoiceauto') - || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { - push @invoicing_list, $cust_main->default_invoicing_list; - } - my $email = $invoicing_list[0]; + if ( $conf->exists('invoice_html') ) { - my( $action1, $action2 ) = split(/\s*\,\s*/, $bop_action ); + warn "$me creating HTML/text multipart message" + if $DEBUG; - my $description = 'Internet Services'; - if ( $conf->exists('business-onlinepayment-description') ) { - my $dtempl = $conf->config('business-onlinepayment-description'); + $return{'nobody'} = 1; - my $agent = $self->cust_main->agent->agent; - my $pkgs = join(', ', - map { $_->cust_pkg->part_pkg->pkg } - grep { $_->pkgnum } $self->cust_bill_pkg - ); - $description = eval qq("$dtempl"); + my $alternative = build MIME::Entity + 'Type' => 'multipart/alternative', + 'Encoding' => '7bit', + 'Disposition' => 'inline' + ; - } - - my $transaction = - new Business::OnlinePayment( $bop_processor, @bop_options ); - $transaction->content( - 'type' => 'CC', - 'login' => $bop_login, - 'password' => $bop_password, - 'action' => $action1, - 'description' => $description, - 'amount' => $amount, - 'invoice_number' => $self->invnum, - 'customer_id' => $self->custnum, - 'last_name' => $paylast, - 'first_name' => $payfirst, - 'name' => $payname, - 'address' => $address, - 'city' => $cust_main->city, - 'state' => $cust_main->state, - 'zip' => $cust_main->zip, - 'country' => $cust_main->country, - 'card_number' => $cust_main->payinfo, - 'expiration' => $exp, - 'referer' => 'http://cleanwhisker.420.am/', - 'email' => $email, - ); - $transaction->submit(); - - if ( $transaction->is_success() && $action2 ) { - my $auth = $transaction->authorization; - my $ordernum = $transaction->order_number; - #warn "********* $auth ***********\n"; - #warn "********* $ordernum ***********\n"; - my $capture = - new Business::OnlinePayment( $bop_processor, @bop_options ); - - $capture->content( - action => $action2, - login => $bop_login, - password => $bop_password, - order_number => $ordernum, - amount => $amount, - authorization => $auth, - description => $description, - ); + my $data; + if ( $conf->exists('invoice_email_pdf') + and scalar($conf->config('invoice_email_pdf_note')) ) { - $capture->submit(); + warn "$me using 'invoice_email_pdf_note' in multipart message" + if $DEBUG; + $data = [ map { $_ . "\n" } + $conf->config('invoice_email_pdf_note') + ]; - unless ( $capture->is_success ) { - my $e = "Authorization sucessful but capture failed, invnum #". - $self->invnum. ': '. $capture->result_code. - ": ". $capture->error_message; - warn $e; - return $e; - } + } else { - } + warn "$me not using 'invoice_email_pdf_note' in multipart message" + if $DEBUG; + if ( ref($args{'print_text'}) eq 'ARRAY' ) { + $data = $args{'print_text'}; + } else { + $data = [ $self->print_text('', $args{'template'}) ]; + } - if ( $transaction->is_success() ) { + } - my $cust_pay = new FS::cust_pay ( { - 'invnum' => $self->invnum, - 'paid' => $amount, - '_date' => '', - 'payby' => 'CARD', - 'payinfo' => $cust_main->payinfo, - 'paybatch' => "$processor:". $transaction->authorization, - } ); - my $error = $cust_pay->insert; - if ( $error ) { - # gah, even with transactions. - my $e = 'WARNING: Card debited but database not updated - '. - 'error applying payment, invnum #' . $self->invnum. - " ($processor): $error"; - warn $e; - return $e; + $alternative->attach( + 'Type' => 'text/plain', + #'Encoding' => 'quoted-printable', + 'Encoding' => '7bit', + 'Data' => $data, + 'Disposition' => 'inline', + ); + + $args{'from'} =~ /\@([\w\.\-]+)/; + my $from = $1 || 'example.com'; + my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; + + my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc"; + my $file; + if ( defined($args{'template'}) && length($args{'template'}) + && -e "$path/logo_". $args{'template'}. ".png" + ) + { + $file = "$path/logo_". $args{'template'}. ".png"; } else { - return ''; + $file = "$path/logo.png"; } - #} elsif ( $options{'report_badcard'} ) { - } else { - return "$processor error, invnum #". $self->invnum. ': '. - $transaction->result_code. ": ". $transaction->error_message; - } -} + my $image = build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Path' => $file, + 'Filename' => 'logo.png', + 'Content-ID' => "<$content_id>", + ; + + $alternative->attach( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', + 'Data' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + $self->print_html('', $args{'template'}, $content_id), + ' ', + '', + ], + 'Disposition' => 'inline', + #'Filename' => 'invoice.pdf', + ); -=item realtime_card_cybercash + if ( $conf->exists('invoice_email_pdf') ) { -Attempts to pay this invoice with the CyberCash CashRegister realtime gateway. + #attaching pdf too: + # multipart/mixed + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + # application/pdf -=cut + my $related = build MIME::Entity 'Type' => 'multipart/related', + 'Encoding' => '7bit'; -sub realtime_card_cybercash { - my $self = shift; - my $cust_main = $self->cust_main; - my $amount = $self->owed; + #false laziness w/Misc::send_email + $related->head->replace('Content-type', + $related->mime_type. + '; boundary="'. $related->head->multipart_boundary. '"'. + '; type=multipart/alternative' + ); - return "CyberCash CashRegister real-time card processing not enabled!" - unless $processor eq 'cybercash3.2'; + $related->add_part($alternative); - my $address = $cust_main->address1; - $address .= ", ". $cust_main->address2 if $cust_main->address2; + $related->add_part($image); - #fix exp. date - #$cust_main->paydate =~ /^(\d+)\/\d*(\d{2})$/; - $cust_main->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; - my $exp = "$2/$1"; + my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'}); - # + $return{'mimeparts'} = [ $related, $pdf ]; - my $paybatch = $self->invnum. - '-' . time2str("%y%m%d%H%M%S", time); + } else { - my $payname = $cust_main->payname || - $cust_main->getfield('first').' '.$cust_main->getfield('last'); + #no other attachment: + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png - my $country = $cust_main->country eq 'US' ? 'USA' : $cust_main->country; + $return{'content-type'} = 'multipart/related'; + $return{'mimeparts'} = [ $alternative, $image ]; + $return{'type'} = 'multipart/alternative'; #Content-Type of first part... + #$return{'disposition'} = 'inline'; - my @full_xaction = ( $xaction, - 'Order-ID' => $paybatch, - 'Amount' => "usd $amount", - 'Card-Number' => $cust_main->getfield('payinfo'), - 'Card-Name' => $payname, - 'Card-Address' => $address, - 'Card-City' => $cust_main->getfield('city'), - 'Card-State' => $cust_main->getfield('state'), - 'Card-Zip' => $cust_main->getfield('zip'), - 'Card-Country' => $country, - 'Card-Exp' => $exp, - ); + } + + } else { + + if ( $conf->exists('invoice_email_pdf') ) { + warn "$me creating PDF attachment" + if $DEBUG; - my %result; - %result = &CCMckDirectLib3_2::SendCC2_1Server(@full_xaction); + #mime parts arguments a la MIME::Entity->build(). + $return{'mimeparts'} = [ + { $self->mimebuild_pdf('', $args{'template'}) } + ]; + } - if ( $result{'MStatus'} eq 'success' ) { #cybercash smps v.2 or 3 - my $cust_pay = new FS::cust_pay ( { - 'invnum' => $self->invnum, - 'paid' => $amount, - '_date' => '', - 'payby' => 'CARD', - 'payinfo' => $cust_main->payinfo, - 'paybatch' => "$processor:$paybatch", - } ); - my $error = $cust_pay->insert; - if ( $error ) { - # gah, even with transactions. - my $e = 'WARNING: Card debited but database not updated - '. - 'error applying payment, invnum #' . $self->invnum. - " (CyberCash Order-ID $paybatch): $error"; - warn $e; - return $e; + if ( $conf->exists('invoice_email_pdf') + and scalar($conf->config('invoice_email_pdf_note')) ) { + + warn "$me using 'invoice_email_pdf_note'" + if $DEBUG; + $return{'body'} = [ map { $_ . "\n" } + $conf->config('invoice_email_pdf_note') + ]; + } else { - return ''; + + warn "$me not using 'invoice_email_pdf_note'" + if $DEBUG; + if ( ref($args{'print_text'}) eq 'ARRAY' ) { + $return{'body'} = $args{'print_text'}; + } else { + $return{'body'} = [ $self->print_text('', $args{'template'}) ]; + } + } -# } elsif ( $result{'Mstatus'} ne 'failure-bad-money' -# || $options{'report_badcard'} -# ) { - } else { - return 'Cybercash error, invnum #' . - $self->invnum. ':'. $result{'MErrMsg'}; + } + %return; + } -=item batch_card +=item mimebuild_pdf -Adds a payment for this invoice to the pending credit card batch (see -L). +Returns a list suitable for passing to MIME::Entity->build(), representing +this invoice as PDF attachment. =cut -sub batch_card { +sub mimebuild_pdf { my $self = shift; - my $cust_main = $self->cust_main; + ( + 'Type' => 'application/pdf', + 'Encoding' => 'base64', + 'Data' => [ $self->print_pdf(@_) ], + 'Disposition' => 'attachment', + 'Filename' => 'invoice.pdf', + ); +} - my $cust_pay_batch = new FS::cust_pay_batch ( { - 'invnum' => $self->getfield('invnum'), - 'custnum' => $cust_main->getfield('custnum'), - 'last' => $cust_main->getfield('last'), - 'first' => $cust_main->getfield('first'), - 'address1' => $cust_main->getfield('address1'), - 'address2' => $cust_main->getfield('address2'), - 'city' => $cust_main->getfield('city'), - 'state' => $cust_main->getfield('state'), - 'zip' => $cust_main->getfield('zip'), - 'country' => $cust_main->getfield('country'), - 'trancode' => 77, - 'cardnum' => $cust_main->getfield('payinfo'), - 'exp' => $cust_main->getfield('paydate'), - 'payname' => $cust_main->getfield('payname'), - 'amount' => $self->owed, - } ); - $cust_pay_batch->insert; +=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ] -} +Sends this invoice to the destinations configured for this customer: sends +email, prints and/or faxes. See L. -=item print_text [TIME]; +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. -Returns an text invoice, as a list of lines. +AGENTNUM, if specified, means that this invoice will only be sent for customers +of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a +single agent) or an arrayref of agentnums. -TIME an optional value used to control the printing of overdue messages. The -default is now. It isn't the date of the invoice; that's the `_date' field. -It is specified as a UNIX timestamp; see L. Also see -L and L for conversion functions. +INVOICE_FROM, if specified, overrides the default email invoice From: address. + +AMOUNT, if specified, only sends the invoice if the total amount owed on this +invoice and all older invoices is greater than the specified amount. =cut -sub print_text { +sub queueable_send { + my %opt = @_; - my( $self, $today, $template ) = @_; - $today ||= time; -# my $invnum = $self->invnum; - my $cust_main = qsearchs('cust_main', { 'custnum', $self->custnum } ); - $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') ) - unless $cust_main->payname; + my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } ) + or die "invalid invoice number: " . $opt{invnum}; - my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance -# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits - #my $balance_due = $self->owed + $pr_total - $cr_total; - my $balance_due = $self->owed + $pr_total; + my @args = ( $opt{template}, $opt{agentnum} ); + push @args, $opt{invoice_from} + if exists($opt{invoice_from}) && $opt{invoice_from}; - #my @collect = (); - #my($description,$amount); - @buf = (); + my $error = $self->send( @args ); + die $error if $error; - #previous balance - foreach ( @pr_cust_bill ) { - push @buf, [ - "Previous Balance, Invoice #". $_->invnum. - " (". time2str("%x",$_->_date). ")", - $money_char. sprintf("%10.2f",$_->owed) - ]; - } - if (@pr_cust_bill) { - push @buf,['','-----------']; - push @buf,[ 'Total Previous Balance', - $money_char. sprintf("%10.2f",$pr_total ) ]; - push @buf,['','']; +} + +sub send { + my $self = shift; + my $template = scalar(@_) ? shift : ''; + if ( scalar(@_) && $_[0] ) { + my $agentnums = ref($_[0]) ? shift : [ shift ]; + return 'N/A' unless grep { $_ == $self->cust_main->agentnum } @$agentnums; } - #new charges - foreach ( $self->cust_bill_pkg ) { + my $invoice_from = + scalar(@_) + ? shift + : ( $self->_agent_invoice_from || $conf->config('invoice_from') ); - if ( $_->pkgnum ) { + my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0; - my($cust_pkg)=qsearchs('cust_pkg', { 'pkgnum', $_->pkgnum } ); - my($part_pkg)=qsearchs('part_pkg',{'pkgpart'=>$cust_pkg->pkgpart}); - my($pkg)=$part_pkg->pkg; + return '' + unless $self->cust_main->total_owed_date($self->_date) > $balance_over; - if ( $_->setup != 0 ) { - push @buf, [ "$pkg Setup", $money_char. sprintf("%10.2f",$_->setup) ]; - push @buf, - map { [ " ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels; - } + my @invoicing_list = $self->cust_main->invoicing_list; - if ( $_->recur != 0 ) { - push @buf, [ - "$pkg (" . time2str("%x",$_->sdate) . " - " . - time2str("%x",$_->edate) . ")", - $money_char. sprintf("%10.2f",$_->recur) - ]; - push @buf, - map { [ " ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels; - } + #$self->email_invoice($template, $invoice_from) + $self->email($template, $invoice_from) + if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list; - } else { #pkgnum Tax - push @buf,["Tax", $money_char. sprintf("%10.2f",$_->setup) ] - if $_->setup != 0; - } - } + #$self->print_invoice($template) + $self->print($template) + if grep { $_ eq 'POST' } @invoicing_list; #postal - push @buf,['','-----------']; - push @buf,['Total New Charges', - $money_char. sprintf("%10.2f",$self->charged) ]; - push @buf,['','']; + $self->fax_invoice($template) + if grep { $_ eq 'FAX' } @invoicing_list; #fax - push @buf,['','-----------']; - push @buf,['Total Charges', - $money_char. sprintf("%10.2f",$self->charged + $pr_total) ]; - push @buf,['','']; + ''; - #credits - foreach ( $self->cust_credited ) { +} - #something more elaborate if $_->amount ne $_->cust_credit->credited ? +=item email [ TEMPLATENAME [ , INVOICE_FROM ] ] - my $reason = substr($_->cust_credit->reason,0,32); - $reason .= '...' if length($reason) < length($_->cust_credit->reason); - $reason = " ($reason) " if $reason; - push @buf,[ - "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")". - $reason, - $money_char. sprintf("%10.2f",$_->amount) - ]; - } - #foreach ( @cr_cust_credit ) { - # push @buf,[ - # "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", - # $money_char. sprintf("%10.2f",$_->credited) - # ]; - #} +Emails this invoice. - #get & print payments - foreach ( $self->cust_bill_pay ) { +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. - #something more elaborate if $_->amount ne ->cust_pay->paid ? +INVOICE_FROM, if specified, overrides the default email invoice From: address. - push @buf,[ - "Payment received ". time2str("%x",$_->cust_pay->_date ), - $money_char. sprintf("%10.2f",$_->amount ) - ]; - } +=cut - #balance due - push @buf,['','-----------']; - push @buf,['Balance Due', $money_char. - sprintf("%10.2f", $balance_due ) ]; +sub queueable_email { + my %opt = @_; - #create the template - my $templatefile = 'invoice_template'; - $templatefile .= "_$template" if $template; - my @invoice_template = $conf->config($templatefile) - or die "cannot load config file $templatefile"; - $invoice_lines = 0; - foreach ( grep /invoice_lines\(\d+\)/, @invoice_template ) { #kludgy - /invoice_lines\((\d+)\)/; - $invoice_lines += $1; - } - die "no invoice_lines() functions in template?" unless $invoice_lines; - my $invoice_template = new Text::Template ( - TYPE => 'ARRAY', - SOURCE => [ map "$_\n", @invoice_template ], - ) or die "can't create new Text::Template object: $Text::Template::ERROR"; - $invoice_template->compile() - or die "can't compile template: $Text::Template::ERROR"; - - #setup template variables - package FS::cust_bill::_template; #! - use vars qw( $invnum $date $page $total_pages @address $overdue @buf ); - - $invnum = $self->invnum; - $date = $self->_date; - $page = 1; - - $total_pages = - int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines ); - $total_pages++ - if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines; - - - #format address (variable for the template) - my $l = 0; - @address = ( '', '', '', '', '', '' ); - package FS::cust_bill; #! - $FS::cust_bill::_template::address[$l++] = - $cust_main->payname. - ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo - ? " (P.O. #". $cust_main->payinfo. ")" - : '' - ) - ; - $FS::cust_bill::_template::address[$l++] = $cust_main->company - if $cust_main->company; - $FS::cust_bill::_template::address[$l++] = $cust_main->address1; - $FS::cust_bill::_template::address[$l++] = $cust_main->address2 - if $cust_main->address2; - $FS::cust_bill::_template::address[$l++] = - $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip; - $FS::cust_bill::_template::address[$l++] = $cust_main->country - unless $cust_main->country eq 'US'; + my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } ) + or die "invalid invoice number: " . $opt{invnum}; - # #overdue? (variable for the template) - # $FS::cust_bill::_template::overdue = ( - # $balance_due > 0 - # && $today > $self->_date - ## && $self->printed > 1 - # && $self->printed > 0 - # ); + my @args = ( $opt{template} ); + push @args, $opt{invoice_from} + if exists($opt{invoice_from}) && $opt{invoice_from}; - #and subroutine for the template + my $error = $self->email( @args ); + die $error if $error; - sub FS::cust_bill::_template::invoice_lines { - my $lines = shift; - map { - scalar(@buf) ? shift @buf : [ '', '' ]; - } - ( 1 .. $lines ); - } +} +#sub email_invoice { +sub email { + my $self = shift; + my $template = scalar(@_) ? shift : ''; + my $invoice_from = + scalar(@_) + ? shift + : ( $self->_agent_invoice_from || $conf->config('invoice_from') ); + + my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } + $self->cust_main->invoicing_list; + + #better to notify this person than silence + @invoicing_list = ($invoice_from) unless @invoicing_list; + + my $error = send_email( + $self->generate_email( + 'from' => $invoice_from, + 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ], + 'template' => $template, + ) + ); + die "can't email invoice: $error\n" if $error; + #die "$error\n" if $error; - #and fill it in - $FS::cust_bill::_template::page = 1; - my $lines; - my @collect; - while (@buf) { - push @collect, split("\n", - $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' ) - ); - $FS::cust_bill::_template::page++; - } +} + +=item lpr_data [ TEMPLATENAME ] + +Returns the postscript or plaintext for this invoice as an arrayref. + +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. - map "$_\n", @collect; +=cut +sub lpr_data { + my( $self, $template) = @_; + $conf->exists('invoice_latex') + ? [ $self->print_ps('', $template) ] + : [ $self->print_text('', $template) ]; } -=back +=item print [ TEMPLATENAME ] -=head1 VERSION +Prints this invoice. -$Id: cust_bill.pm,v 1.26 2002-04-07 06:23:29 ivan Exp $ +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. -=head1 BUGS +=cut -The delete method. +#sub print_invoice { +sub print { + my $self = shift; + my $template = scalar(@_) ? shift : ''; + + do_print $self->lpr_data($template); +} -print_text formatting (and some logic :/) is in source, but needs to be -slurped in from a file. Also number of lines ($=). +=item fax_invoice [ TEMPLATENAME ] + +Faxes this invoice. + +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. + +=cut -missing print_ps for a nice postscript copy (maybe HylaFAX-cover-page-style -or something similar so the look can be completely customized?) +sub fax_invoice { + my $self = shift; + my $template = scalar(@_) ? shift : ''; + + die 'FAX invoice destination not (yet?) supported with plain text invoices.' + unless $conf->exists('invoice_latex'); + + my $dialstring = $self->cust_main->getfield('fax'); + #Check $dialstring? + + my $error = send_fax( 'docdata' => $self->lpr_data($template), + 'dialstring' => $dialstring, + ); + die $error if $error; + +} + +=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ] + +Like B, but only sends the invoice if it is the newest open invoice for +this customer. + +=cut + +sub send_if_newest { + my $self = shift; + + return '' + if scalar( + grep { $_->owed > 0 } + qsearch('cust_bill', { + 'custnum' => $self->custnum, + #'_date' => { op=>'>', value=>$self->_date }, + 'invnum' => { op=>'>', value=>$self->invnum }, + } ) + ); + + $self->send(@_); +} + +=item send_csv OPTION => VALUE, ... + +Sends invoice as a CSV data-file to a remote host with the specified protocol. + +Options are: + +protocol - currently only "ftp" +server +username +password +dir + +The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number +and YYMMDDHHMMSS is a timestamp. + +See L for a description of the output format. + +=cut + +sub send_csv { + my($self, %opt) = @_; + + #create file(s) + + my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; + mkdir $spooldir, 0700 unless -d $spooldir; + + my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time); + my $file = "$spooldir/$tracctnum.csv"; + + my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum ); + + open(CSV, ">$file") or die "can't open $file: $!"; + print CSV $header; + + print CSV $detail; + + close CSV; + + my $net; + if ( $opt{protocol} eq 'ftp' ) { + eval "use Net::FTP;"; + die $@ if $@; + $net = Net::FTP->new($opt{server}) or die @$; + } else { + die "unknown protocol: $opt{protocol}"; + } + + $net->login( $opt{username}, $opt{password} ) + or die "can't FTP to $opt{username}\@$opt{server}: login error: $@"; + + $net->binary or die "can't set binary mode"; + + $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}"; + + $net->put($file) or die "can't put $file: $!"; + + $net->quit; + + unlink $file; + +} + +=item spool_csv + +Spools CSV invoice data. + +Options are: + +=over 4 + +=item format - 'default' or 'billco' + +=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L). + +=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file + +=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount. + +=back + +=cut + +sub spool_csv { + my($self, %opt) = @_; + + my $cust_main = $self->cust_main; + + if ( $opt{'dest'} ) { + my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 } + $cust_main->invoicing_list; + return 'N/A' unless $invoicing_list{$opt{'dest'}} + || ! keys %invoicing_list; + } + + if ( $opt{'balanceover'} ) { + return 'N/A' + if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'}; + } + + my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; + mkdir $spooldir, 0700 unless -d $spooldir; + + my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time); + + my $file = + "$spooldir/". + ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ). + ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) . + '.csv'; + + my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum ); + + open(CSV, ">>$file") or die "can't open $file: $!"; + flock(CSV, LOCK_EX); + seek(CSV, 0, 2); + + print CSV $header; + + if ( lc($opt{'format'}) eq 'billco' ) { + + flock(CSV, LOCK_UN); + close CSV; + + $file = + "$spooldir/". + ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ). + '-detail.csv'; + + open(CSV,">>$file") or die "can't open $file: $!"; + flock(CSV, LOCK_EX); + seek(CSV, 0, 2); + } + + print CSV $detail; + + flock(CSV, LOCK_UN); + close CSV; + + return ''; + +} + +=item print_csv OPTION => VALUE, ... + +Returns CSV data for this invoice. + +Options are: + +format - 'default' or 'billco' + +Returns a list consisting of two scalars. The first is a single line of CSV +header information for this invoice. The second is one or more lines of CSV +detail information for this invoice. + +If I is not specified or "default", the fields of the CSV file are as +follows: + +record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate + +=over 4 + +=item record type - B is either C or C + +B is C for the initial header line only. The +last five fields (B through B) are irrelevant, and all other +fields are filled in. + +B is C for detail lines. Only the first two fields +(B and B) and the last five fields (B through B) +are filled in. + +=item invnum - invoice number + +=item custnum - customer number + +=item _date - invoice date + +=item charged - total invoice amount + +=item first - customer first name + +=item last - customer first name + +=item company - company name + +=item address1 - address line 1 + +=item address2 - address line 1 + +=item city + +=item state + +=item zip + +=item country + +=item pkg - line item description + +=item setup - line item setup fee (one or both of B and B will be defined) + +=item recur - line item recurring fee (one or both of B and B will be defined) + +=item sdate - start date for recurring fee + +=item edate - end date for recurring fee + +=back + +If I is "billco", the fields of the header CSV file are as follows: + + +-------------------------------------------------------------------+ + | FORMAT HEADER FILE | + |-------------------------------------------------------------------| + | Field | Description | Name | Type | Width | + | 1 | N/A-Leave Empty | RC | CHAR | 2 | + | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 | + | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 | + | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 | + | 5 | Transaction Zip Code | TRZIP | CHAR | 5 | + | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 | + | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 | + | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 | + | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 | + | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 | + | 11 | Transaction City Bill To | TRCITY | CHAR | 20 | + | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 | + | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 | + | 14 | Bill Due Date | DUEDATE | CHAR | 10 | + | 15 | Previous Balance | BALFWD | NUM* | 9 | + | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 | + | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 | + | 18 | Total Amt Due | TOTALDUE | NUM* | 9 | + | 19 | Total Amt Due | AMTDUE | NUM* | 9 | + | 20 | 30 Day Aging | AMT30 | NUM* | 9 | + | 21 | 60 Day Aging | AMT60 | NUM* | 9 | + | 22 | 90 Day Aging | AMT90 | NUM* | 9 | + | 23 | Y/N | AGESWITCH | CHAR | 1 | + | 24 | Remittance automation | SCANLINE | CHAR | 100 | + | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 | + | 26 | Customer Reference Number | CUSTREF | CHAR | 15 | + | 27 | Federal Tax*** | FEDTAX | NUM* | 9 | + | 28 | State Tax*** | STATETAX | NUM* | 9 | + | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 | + +-------+-------------------------------+------------+------+-------+ + +If I is "billco", the fields of the detail CSV file are as follows: + + FORMAT FOR DETAIL FILE + | | | | + Field | Description | Name | Type | Width + 1 | N/A-Leave Empty | RC | CHAR | 2 + 2 | N/A-Leave Empty | CUSTID | CHAR | 15 + 3 | Account Number | TRACCTNUM | CHAR | 15 + 4 | Invoice Number | TRINVOICE | CHAR | 15 + 5 | Line Sequence (sort order) | LINESEQ | NUM | 6 + 6 | Transaction Detail | DETAILS | CHAR | 100 + 7 | Amount | AMT | NUM* | 9 + 8 | Line Format Control** | LNCTRL | CHAR | 2 + 9 | Grouping Code | GROUP | CHAR | 2 + 10 | User Defined | ACCT CODE | CHAR | 15 + +=cut + +sub print_csv { + my($self, %opt) = @_; + + eval "use Text::CSV_XS"; + die $@ if $@; + + my $cust_main = $self->cust_main; + + my $csv = Text::CSV_XS->new({'always_quote'=>1}); + + if ( lc($opt{'format'}) eq 'billco' ) { + + my $taxtotal = 0; + $taxtotal += $_->{'amount'} foreach $self->_items_tax; + + my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format? + + my( $previous_balance, @unused ) = $self->previous; #previous balance + + my $pmt_cr_applied = 0; + $pmt_cr_applied += $_->{'amount'} + foreach ( $self->_items_payments, $self->_items_credits ) ; + + my $totaldue = sprintf('%.2f', $self->owed + $previous_balance); + + $csv->combine( + '', # 1 | N/A-Leave Empty CHAR 2 + '', # 2 | N/A-Leave Empty CHAR 15 + $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15 + $self->invnum, # 4 | Transaction Invoice No CHAR 15 + $cust_main->zip, # 5 | Transaction Zip Code CHAR 5 + $cust_main->company, # 6 | Transaction Company Bill To CHAR 30 + #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30 + $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30 + $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30 + $cust_main->address1, # 9 | Bill To Street Address CHAR 30 + '', # 10 | Ancillary Billing Information CHAR 30 + $cust_main->city, # 11 | Transaction City Bill To CHAR 20 + $cust_main->state, # 12 | Transaction State Bill To CHAR 2 + + # XXX ? + time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10 + + # XXX ? + $duedate, # 14 | Bill Due Date CHAR 10 + + $previous_balance, # 15 | Previous Balance NUM* 9 + $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9 + sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9 + $totaldue, # 18 | Total Amt Due NUM* 9 + $totaldue, # 19 | Total Amt Due NUM* 9 + '', # 20 | 30 Day Aging NUM* 9 + '', # 21 | 60 Day Aging NUM* 9 + '', # 22 | 90 Day Aging NUM* 9 + 'N', # 23 | Y/N CHAR 1 + '', # 24 | Remittance automation CHAR 100 + $taxtotal, # 25 | Total Taxes & Fees NUM* 9 + $self->custnum, # 26 | Customer Reference Number CHAR 15 + '0', # 27 | Federal Tax*** NUM* 9 + sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9 + '0', # 29 | Other Taxes & Fees*** NUM* 9 + ); + + } else { + + $csv->combine( + 'cust_bill', + $self->invnum, + $self->custnum, + time2str("%x", $self->_date), + sprintf("%.2f", $self->charged), + ( map { $cust_main->getfield($_) } + qw( first last company address1 address2 city state zip country ) ), + map { '' } (1..5), + ) or die "can't create csv"; + } + + my $header = $csv->string. "\n"; + + my $detail = ''; + if ( lc($opt{'format'}) eq 'billco' ) { + + my $lineseq = 0; + foreach my $item ( $self->_items_pkg ) { + + $csv->combine( + '', # 1 | N/A-Leave Empty CHAR 2 + '', # 2 | N/A-Leave Empty CHAR 15 + $opt{'tracctnum'}, # 3 | Account Number CHAR 15 + $self->invnum, # 4 | Invoice Number CHAR 15 + $lineseq++, # 5 | Line Sequence (sort order) NUM 6 + $item->{'description'}, # 6 | Transaction Detail CHAR 100 + $item->{'amount'}, # 7 | Amount NUM* 9 + '', # 8 | Line Format Control** CHAR 2 + '', # 9 | Grouping Code CHAR 2 + '', # 10 | User Defined CHAR 15 + ); + + $detail .= $csv->string. "\n"; + + } + + } else { + + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + + my($pkg, $setup, $recur, $sdate, $edate); + if ( $cust_bill_pkg->pkgnum ) { + + ($pkg, $setup, $recur, $sdate, $edate) = ( + $cust_bill_pkg->part_pkg->pkg, + ( $cust_bill_pkg->setup != 0 + ? sprintf("%.2f", $cust_bill_pkg->setup ) + : '' ), + ( $cust_bill_pkg->recur != 0 + ? sprintf("%.2f", $cust_bill_pkg->recur ) + : '' ), + ( $cust_bill_pkg->sdate + ? time2str("%x", $cust_bill_pkg->sdate) + : '' ), + ($cust_bill_pkg->edate + ?time2str("%x", $cust_bill_pkg->edate) + : '' ), + ); + + } else { #pkgnum tax + next unless $cust_bill_pkg->setup != 0; + my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc') + ? ( $cust_bill_pkg->itemdesc || 'Tax' ) + : 'Tax'; + ($pkg, $setup, $recur, $sdate, $edate) = + ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' ); + } + + $csv->combine( + 'cust_bill_pkg', + $self->invnum, + ( map { '' } (1..11) ), + ($pkg, $setup, $recur, $sdate, $edate) + ) or die "can't create csv"; + + $detail .= $csv->string. "\n"; + + } + + } + + ( $header, $detail ); + +} + +=item comp + +Pays this invoice with a compliemntary payment. If there is an error, +returns the error, otherwise returns false. + +=cut + +sub comp { + my $self = shift; + my $cust_pay = new FS::cust_pay ( { + 'invnum' => $self->invnum, + 'paid' => $self->owed, + '_date' => '', + 'payby' => 'COMP', + 'payinfo' => $self->cust_main->payinfo, + 'paybatch' => '', + } ); + $cust_pay->insert; +} + +=item realtime_card + +Attempts to pay this invoice with a credit card payment via a +Business::OnlinePayment realtime gateway. See +http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment +for supported processors. + +=cut + +sub realtime_card { + my $self = shift; + $self->realtime_bop( 'CC', @_ ); +} + +=item realtime_ach + +Attempts to pay this invoice with an electronic check (ACH) payment via a +Business::OnlinePayment realtime gateway. See +http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment +for supported processors. + +=cut + +sub realtime_ach { + my $self = shift; + $self->realtime_bop( 'ECHECK', @_ ); +} + +=item realtime_lec + +Attempts to pay this invoice with phone bill (LEC) payment via a +Business::OnlinePayment realtime gateway. See +http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment +for supported processors. + +=cut + +sub realtime_lec { + my $self = shift; + $self->realtime_bop( 'LEC', @_ ); +} + +sub realtime_bop { + my( $self, $method ) = @_; + + my $cust_main = $self->cust_main; + my $balance = $cust_main->balance; + my $amount = ( $balance < $self->owed ) ? $balance : $self->owed; + $amount = sprintf("%.2f", $amount); + return "not run (balance $balance)" unless $amount > 0; + + my $description = 'Internet Services'; + if ( $conf->exists('business-onlinepayment-description') ) { + my $dtempl = $conf->config('business-onlinepayment-description'); + + my $agent_obj = $cust_main->agent + or die "can't retreive agent for $cust_main (agentnum ". + $cust_main->agentnum. ")"; + my $agent = $agent_obj->agent; + my $pkgs = join(', ', + map { $_->part_pkg->pkg } + grep { $_->pkgnum } $self->cust_bill_pkg + ); + $description = eval qq("$dtempl"); + } + + $cust_main->realtime_bop($method, $amount, + 'description' => $description, + 'invnum' => $self->invnum, + ); + +} + +=item batch_card OPTION => VALUE... + +Adds a payment for this invoice to the pending credit card batch (see +L), or, if the B option is set to a true value, +runs the payment using a realtime gateway. + +=cut + +sub batch_card { + my ($self, %options) = @_; + my $cust_main = $self->cust_main; + + $options{invnum} = $self->invnum; + + $cust_main->batch_card(%options); +} + +sub _agent_template { + my $self = shift; + $self->cust_main->agent_template; +} + +sub _agent_invoice_from { + my $self = shift; + $self->cust_main->agent_invoice_from; +} + +=item print_text [ TIME [ , TEMPLATE ] ] + +Returns an text invoice, as a list of lines. + +TIME an optional value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +=cut + +sub print_text { + my( $self, $today, $template ) = @_; + + my %params = ( 'format' => 'template' ); + $params{'time'} = $today if $today; + $params{'template'} = $template if $template; + + $self->print_generic( %params ); +} + +=item print_latex [ TIME [ , TEMPLATE ] ] + +Internal method - returns a filename of a filled-in LaTeX template for this +invoice (Note: add ".tex" to get the actual filename), and a filename of +an associated logo (with the .eps extension included). + +See print_ps and print_pdf for methods that return PostScript and PDF output. + +TIME an optional value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +=cut + +sub print_latex { + + my( $self, $today, $template ) = @_; + + my %params = ( 'format' => 'latex' ); + $params{'time'} = $today if $today; + $params{'template'} = $template if $template; + + $template ||= $self->_agent_template; + + my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc; + my $lh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX', + DIR => $dir, + SUFFIX => '.eps', + UNLINK => 0, + ) or die "can't open temp file: $!\n"; + + if ($template && $conf->exists("logo_${template}.eps")) { + print $lh $conf->config_binary("logo_${template}.eps") + or die "can't write temp file: $!\n"; + }else{ + print $lh $conf->config_binary('logo.eps') + or die "can't write temp file: $!\n"; + } + close $lh; + $params{'logo_file'} = $lh->filename; + + my @filled_in = $self->print_generic( %params ); + + my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX', + DIR => $dir, + SUFFIX => '.tex', + UNLINK => 0, + ) or die "can't open temp file: $!\n"; + print $fh join('', @filled_in ); + close $fh; + + $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename; + return ($1, $params{'logo_file'}); + +} + +=item print_generic OPTIONS_HASH + +Internal method - returns a filled-in template for this invoice as a scalar. + +See print_ps and print_pdf for methods that return PostScript and PDF output. + +Non optional options include + format - latex, html, template + +Optional options include + +template - a value used as a suffix for a configuration template + +time - a value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +cid - + +=cut + +sub print_generic { + + my( $self, %params ) = @_; + my $today = $params{today} ? $params{today} : time; + warn "FS::cust_bill::print_generic called on $self with suffix $params{template}\n" + if $DEBUG; + + my $format = $params{format}; + die "Unknown format: $format" + unless $format =~ /^(latex|html|template)$/; + + my $cust_main = $self->cust_main; + $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') ) + unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/; + + + my %delimiters = ( 'latex' => [ '[@--', '--@]' ], + 'html' => [ '<%=', '%>' ], + 'template' => [ '{', '}' ], + ); + + #create the template + my $template = $params{template} ? $params{template} : $self->_agent_template; + my $templatefile = "invoice_$format"; + $templatefile .= "_$template" + if length($template); + my @invoice_template = map "$_\n", $conf->config($templatefile) + or die "cannot load config data $templatefile"; + + my $old_latex = ''; + if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) { + #change this to a die when the old code is removed + warn "old-style invoice template $templatefile; ". + "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n"; + $old_latex = 'true'; + @invoice_template = _translate_old_latex_format(@invoice_template); + } + + my $text_template = new Text::Template( + TYPE => 'ARRAY', + SOURCE => \@invoice_template, + DELIMITERS => $delimiters{$format}, + ); + + $text_template->compile() + or die "Can't compile $templatefile: $Text::Template::ERROR\n"; + + + # additional substitution could possibly cause breakage in existing templates + my %convert_maps = ( + 'latex' => { + 'notes' => sub { map "$_", @_ }, + 'footer' => sub { map "$_", @_ }, + 'smallfooter' => sub { map "$_", @_ }, + 'returnaddress' => sub { map "$_", @_ }, + 'coupon' => sub { map "$_", @_ }, + }, + 'html' => { + 'notes' => + sub { + map { + s/%%(.*)$//g; + s/\\section\*\{\\textsc\{(.)(.*)\}\}/

$1<\/font>\U$2<\/b>/g; + s/\\begin\{enumerate\}/

    /g; + s/\\item /
  1. /g; + s/\\end\{enumerate\}/<\/ol>/g; + s/\\textbf\{(.*)\}/$1<\/b>/g; + s/\\\\\*/
    /g; + s/\\dollar ?/\$/g; + s/\\#/#/g; + s/~/ /g; + $_; + } @_ + }, + 'footer' => + sub { map { s/~/ /g; s/\\\\\*?\s*$/
    /; $_; } @_ }, + 'smallfooter' => + sub { map { s/~/ /g; s/\\\\\*?\s*$/
    /; $_; } @_ }, + 'returnaddress' => + sub { + map { + s/~/ /g; + s/\\\\\*?\s*$/
    /; + s/\\hyphenation\{[\w\s\-]+}//; + $_; + } @_ + }, + 'coupon' => sub { "" }, + }, + 'template' => { + 'notes' => + sub { + map { + s/%%.*$//g; + s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g; + s/\\begin\{enumerate\}//g; + s/\\item / * /g; + s/\\end\{enumerate\}//g; + s/\\textbf\{(.*)\}/$1/g; + s/\\\\\*/ /; + s/\\dollar ?/\$/g; + $_; + } @_ + }, + 'footer' => + sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ }, + 'smallfooter' => + sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ }, + 'returnaddress' => + sub { + map { + s/~/ /g; + s/\\\\\*?\s*$/\n/; # dubious + s/\\hyphenation\{[\w\s\-]+}//; + $_; + } @_ + }, + 'coupon' => sub { "" }, + }, + ); + + + # hashes for differing output formats + my %nbsps = ( 'latex' => '~', + 'html' => '', # '&nbps;' would be nice + 'template' => '', # not used + ); + my $nbsp = $nbsps{$format}; + + my %escape_functions = ( 'latex' => \&_latex_escape, + 'html' => \&encode_entities, + 'template' => sub { shift }, + ); + my $escape_function = $escape_functions{$format}; + + my %date_formats = ( 'latex' => '%b %o, %Y', + 'html' => '%b %o, %Y', + 'template' => '%s', + ); + my $date_format = $date_formats{$format}; + + my %embolden_functions = ( 'latex' => sub { return '\textbf{'. shift(). '}' + }, + 'html' => sub { return ''. shift(). '' + }, + 'template' => sub { shift }, + ); + my $embolden_function = $embolden_functions{$format}; + + + # generate template variables + my $returnaddress; + if ( + defined( $conf->config_orbase( "invoice_${format}returnaddress", + $template + ) + ) + && length( $conf->config_orbase( "invoice_${format}returnaddress", + $template + ) + ) + ) { + + $returnaddress = join("\n", + $conf->config_orbase("invoice_${format}returnaddress", $template) + ); + + } elsif ( grep /\S/, + $conf->config_orbase('invoice_latexreturnaddress', $template) ) { + + my $convert_map = $convert_maps{$format}{'returnaddress'}; + $returnaddress = + join( "\n", + &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress", + $template + ) + ) + ); + } elsif ( grep /\S/, $conf->config('company_address') ) { + + my $convert_map = $convert_maps{$format}{'returnaddress'}; + $returnaddress = join( "\n", &$convert_map( + map { s/( {2,})/'~' x length($1)/eg; + s/$/\\\\\*/; + $_ + } + ( $conf->config('company_name'), + $conf->config('company_address'), + ) + ) + ); + + } else { + + my $warning = "Couldn't find a return address; ". + "do you need to set the company_address configuration value?"; + warn "$warning\n"; + $returnaddress = $nbsp; + #$returnaddress = $warning; + + } + + my %invoice_data = ( + 'company_name' => scalar( $conf->config('company_name') ), + 'company_address' => join("\n", $conf->config('company_address') ). "\n", + 'custnum' => $self->custnum, + 'invnum' => $self->invnum, + 'date' => time2str($date_format, $self->_date), + 'today' => time2str('%b %o, %Y', $today), + 'agent' => &$escape_function($cust_main->agent->agent), + 'agent_custid' => &$escape_function($cust_main->agent_custid), + 'payname' => &$escape_function($cust_main->payname), + 'company' => &$escape_function($cust_main->company), + 'address1' => &$escape_function($cust_main->address1), + 'address2' => &$escape_function($cust_main->address2), + 'city' => &$escape_function($cust_main->city), + 'state' => &$escape_function($cust_main->state), + 'zip' => &$escape_function($cust_main->zip), + 'returnaddress' => $returnaddress, + #'quantity' => 1, + 'terms' => $self->terms, + 'template' => $params{'template'}, + #'notes' => join("\n", $conf->config('invoice_latexnotes') ), + # better hang on to conf_dir for a while + 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc", + 'page' => 1, + 'total_pages' => 1, + 'ship_enable' => $conf->exists('invoice-ship_address'), + 'unitprices' => $conf->exists('invoice-unitprice'), + ); + + my $prefix = $cust_main->has_ship_address ? 'ship_' : ''; + foreach ( qw( contact company address1 address2 city state zip country fax) ){ + my $method = $prefix.$_; + $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method); + } + + $invoice_data{'cid'} = $params{'cid'} + if $params{'cid'}; + + my $countrydefault = $conf->config('countrydefault') || 'US'; + if ( $cust_main->country eq $countrydefault ) { + $invoice_data{'country'} = ''; + } else { + $invoice_data{'country'} = &$escape_function(code2country($cust_main->country)); + } + + my @address = (); + $invoice_data{'address'} = \@address; + push @address, + $cust_main->payname. + ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo + ? " (P.O. #". $cust_main->payinfo. ")" + : '' + ) + ; + push @address, $cust_main->company + if $cust_main->company; + push @address, $cust_main->address1; + push @address, $cust_main->address2 + if $cust_main->address2; + push @address, + $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip; + push @address, $invoice_data{'country'} + if $invoice_data{'country'}; + push @address, '' + while (scalar(@address) < 5); + + $invoice_data{'logo_file'} = $params{'logo_file'} + if $params{'logo_file'}; + + my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance +# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits + #my $balance_due = $self->owed + $pr_total - $cr_total; + my $balance_due = $self->owed + $pr_total; + $invoice_data{'balance'} = $balance_due; + + #do variable substitution in notes, footer, smallfooter + foreach my $include (qw( notes footer smallfooter coupon )) { + + my $inc_file = $conf->key_orbase("invoice_${format}$include", $template); + my @inc_src; + + if ( $conf->exists($inc_file) && length( $conf->config($inc_file) ) ) { + + @inc_src = $conf->config($inc_file); + + } else { + + $inc_file = $conf->key_orbase("invoice_latex$include", $template); + + my $convert_map = $convert_maps{$format}{$include}; + + @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g; + s/--\@\]/$delimiters{$format}[1]/g; + $_; + } + &$convert_map( $conf->config($inc_file) ); + + } + + my $inc_tt = new Text::Template ( + TYPE => 'ARRAY', + SOURCE => [ map "$_\n", @inc_src ], + DELIMITERS => $delimiters{$format}, + ) or die "Can't create new Text::Template object: $Text::Template::ERROR"; + + unless ( $inc_tt->compile() ) { + my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n"; + warn $error. "Template:\n". join('', map "$_\n", @inc_src); + die $error; + } + + $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data ); + + $invoice_data{$include} =~ s/\n+$// + if ($format eq 'latex'); + } + + $invoice_data{'po_line'} = + ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) + ? &$escape_function("Purchase Order #". $cust_main->payinfo) + : $nbsp; + + my %money_chars = ( 'latex' => '', + 'html' => $conf->config('money_char') || '$', + 'template' => '', + ); + my $money_char = $money_chars{$format}; + + my %other_money_chars = ( 'latex' => '\dollar ', + 'html' => $conf->config('money_char') || '$', + 'template' => '', + ); + my $other_money_char = $other_money_chars{$format}; + + my @detail_items = (); + my @total_items = (); + my @buf = (); + my @sections = (); + + $invoice_data{'detail_items'} = \@detail_items; + $invoice_data{'total_items'} = \@total_items; + $invoice_data{'buf'} = \@buf; + $invoice_data{'sections'} = \@sections; + + my $previous_section = { 'description' => 'Previous Charges', + 'subtotal' => $other_money_char. + sprintf('%.2f', $pr_total), + }; + + my $taxtotal = 0; + my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees', + 'subtotal' => $taxtotal }; # adjusted below + + my $adjusttotal = 0; + my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments', + 'subtotal' => 0 }; # adjusted below + + my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum); + if ( $multisection ) { + push @sections, $self->_items_sections; + }else{ + push @sections, { 'description' => '', 'subtotal' => '' }; + } + + foreach my $line_item ( $conf->exists('disable_previous_balance') + ? () + : $self->_items_previous + ) + { + my $detail = { + ext_description => [], + }; + $detail->{'ref'} = $line_item->{'pkgnum'}; + $detail->{'quantity'} = 1; + $detail->{'section'} = $previous_section; + $detail->{'description'} = &$escape_function($line_item->{'description'}); + if ( exists $line_item->{'ext_description'} ) { + @{$detail->{'ext_description'}} = map { + &$escape_function($_); + } @{$line_item->{'ext_description'}}; + } + $detail->{'amount'} = ( $old_latex ? '' : $money_char). + $line_item->{'amount'}; + $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + + push @detail_items, $detail; + push @buf, [ $detail->{'description'}, + $money_char. sprintf("%10.2f", $line_item->{'amount'}), + ]; + } + + if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) { + push @buf, ['','-----------']; + push @buf, [ 'Total Previous Balance', + $money_char. sprintf("%10.2f", $pr_total) ]; + push @buf, ['','']; + } + + foreach my $section (@sections) { + + $section->{'subtotal'} = $other_money_char. + sprintf('%.2f', $section->{'subtotal'}) + if $multisection; + + if ( $section->{'description'} ) { + push @buf, ( [ &$escape_function($section->{'description'}), '' ], + [ '', '' ], + ); + } + + my %options = (); + $options{'section'} = $section if $multisection; + $options{'format'} = $format; + $options{'escape_function'} = $escape_function; + + foreach my $line_item ( $self->_items_pkg(%options) ) { + my $detail = { + ext_description => [], + }; + $detail->{'ref'} = $line_item->{'pkgnum'}; + $detail->{'quantity'} = $line_item->{'quantity'}; + $detail->{'section'} = $section; + $detail->{'description'} = &$escape_function($line_item->{'description'}); + if ( exists $line_item->{'ext_description'} ) { + @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}}; + } + $detail->{'amount'} = ( $old_latex ? '' : $money_char ). + $line_item->{'amount'}; + $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). + $line_item->{'unit_amount'}; + $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + + push @detail_items, $detail; + push @buf, ( [ $detail->{'description'}, + $money_char. sprintf("%10.2f", $line_item->{'amount'}), + ], + map { [ " ". $_, '' ] } @{$detail->{'ext_description'}}, + ); + } + + if ( $section->{'description'} ) { + push @buf, ( ['','-----------'], + [ $section->{'description'}. ' sub-total', + $money_char. sprintf("%10.2f", $section->{'subtotal'}) + ], + [ '', '' ], + [ '', '' ], + ); + } + + } + + if ( $multisection && !$conf->exists('disable_previous_balance') ) { + unshift @sections, $previous_section if $pr_total; + } + + foreach my $tax ( $self->_items_tax ) { + my $total = {}; + $total->{'total_item'} = &$escape_function($tax->{'description'}); + $taxtotal += $tax->{'amount'}; + $total->{'total_amount'} = $other_money_char. $tax->{'amount'}; + if ( $multisection ) { + my $money = $old_latex ? '' : $money_char; + push @detail_items, { + ext_description => [], + ref => '', + quantity => '', + description => &$escape_function($tax->{'description'}), + amount => $money. $tax->{'amount'}, + product_code => '', + section => $tax_section, + }; + }else{ + push @total_items, $total; + } + push @buf,[ $total->{'total_item'}, + $money_char. sprintf("%10.2f", $total->{'total_amount'}), + ]; + + } + + if ( $taxtotal ) { + my $total = {}; + $total->{'total_item'} = 'Sub-total'; + $total->{'total_amount'} = + $other_money_char. sprintf('%.2f', $self->charged - $taxtotal ); + + if ( $multisection ) { + $tax_section->{'subtotal'} = $other_money_char. + sprintf('%.2f', $taxtotal); + $tax_section->{'pretotal'} = 'New charges sub-total '. + $total->{'total_amount'}; + push @sections, $tax_section if $taxtotal; + }else{ + unshift @total_items, $total; + } + } + + push @buf,['','-----------']; + push @buf,[( $conf->exists('disable_previous_balance') + ? 'Total Charges' + : 'Total New Charges' + ), + $money_char. sprintf("%10.2f",$self->charged) ]; + push @buf,['','']; + + { + my $total = {}; + $total->{'total_item'} = &$embolden_function('Total'); + $total->{'total_amount'} = + &$embolden_function( + $other_money_char. + sprintf( '%.2f', + $self->charged + ( $conf->exists('disable_previous_balance') + ? 0 + : $pr_total + ) + ) + ); + if ( $multisection ) { + $adjust_section->{'pretotal'} = 'New charges total '. + $total->{'total_amount'}; + }else{ + push @total_items, $total; + } + push @buf,['','-----------']; + push @buf,['Total Charges', + $money_char. + sprintf( '%10.2f', $self->charged + + ( $conf->exists('disable_previous_balance') + ? 0 + : $pr_total + ) + ) + ]; + push @buf,['','']; + } + + unless ( $conf->exists('disable_previous_balance') ) { + #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments + + # credits + foreach my $credit ( $self->_items_credits ) { + my $total; + $total->{'total_item'} = &$escape_function($credit->{'description'}); + #$credittotal + $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'}; + $adjusttotal += $credit->{'amount'}; + if ( $multisection ) { + my $money = $old_latex ? '' : $money_char; + push @detail_items, { + ext_description => [], + ref => '', + quantity => '', + description => &$escape_function($credit->{'description'}), + amount => $money. $credit->{'amount'}, + product_code => '', + section => $adjust_section, + }; + }else{ + push @total_items, $total; + } + } + + # credits (again) + foreach ( $self->cust_credited ) { + + #something more elaborate if $_->amount ne $_->cust_credit->credited ? + + my $reason = substr($_->cust_credit->reason,0,32); + $reason .= '...' if length($reason) < length($_->cust_credit->reason); + $reason = " ($reason) " if $reason; + push @buf,[ + "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")". $reason, + $money_char. sprintf("%10.2f",$_->amount) + ]; + } + + # payments + foreach my $payment ( $self->_items_payments ) { + my $total = {}; + $total->{'total_item'} = &$escape_function($payment->{'description'}); + #$paymenttotal + $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'}; + $adjusttotal += $payment->{'amount'}; + if ( $multisection ) { + my $money = $old_latex ? '' : $money_char; + push @detail_items, { + ext_description => [], + ref => '', + quantity => '', + description => &$escape_function($payment->{'description'}), + amount => $money. $payment->{'amount'}, + product_code => '', + section => $adjust_section, + }; + }else{ + push @total_items, $total; + } + push @buf, [ $payment->{'description'}, + $money_char. sprintf("%10.2f", $payment->{'amount'}), + ]; + } + + if ( $multisection ) { + $adjust_section->{'subtotal'} = $other_money_char. + sprintf('%.2f', $adjusttotal); + push @sections, $adjust_section; + } + + { + my $total; + $total->{'total_item'} = &$embolden_function($self->balance_due_msg); + $total->{'total_amount'} = + &$embolden_function( + $other_money_char. sprintf('%.2f', $self->owed + $pr_total ) + ); + if ( $multisection ) { + $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '. + $total->{'total_amount'}; + }else{ + push @total_items, $total; + } + push @buf,['','-----------']; + push @buf,[$self->balance_due_msg, $money_char. + sprintf("%10.2f", $balance_due ) ]; + } + } + + $invoice_lines = 0; + my $wasfunc = 0; + foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy + /invoice_lines\((\d*)\)/; + $invoice_lines += $1 || scalar(@buf); + $wasfunc=1; + } + die "no invoice_lines() functions in template?" + if ( $format eq 'template' && !$wasfunc ); + + if ($format eq 'template') { + + if ( $invoice_lines ) { + $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines ); + $invoice_data{'total_pages'}++ + if scalar(@buf) % $invoice_lines; + } + + #setup subroutine for the template + sub FS::cust_bill::_template::invoice_lines { + my $lines = shift || scalar(@FS::cust_bill::_template::buf); + map { + scalar(@FS::cust_bill::_template::buf) + ? shift @FS::cust_bill::_template::buf + : [ '', '' ]; + } + ( 1 .. $lines ); + } + + my $lines; + my @collect; + while (@buf) { + push @collect, split("\n", + $text_template->fill_in( HASH => \%invoice_data, + PACKAGE => 'FS::cust_bill::_template' + ) + ); + $FS::cust_bill::_template::page++; + } + map "$_\n", @collect; + }else{ + warn "filling in template for invoice ". $self->invnum. "\n" + if $DEBUG; + warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n" + if $DEBUG > 1; + + $text_template->fill_in(HASH => \%invoice_data); + } +} + +=item print_ps [ TIME [ , TEMPLATE ] ] + +Returns an postscript invoice, as a scalar. + +TIME an optional value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +=cut + +sub print_ps { + my $self = shift; + + my ($file, $lfile) = $self->print_latex(@_); + my $ps = generate_ps($file); + unlink($lfile); + + $ps; +} + +=item print_pdf [ TIME [ , TEMPLATE ] ] + +Returns an PDF invoice, as a scalar. + +TIME an optional value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +=cut + +sub print_pdf { + my $self = shift; + + my ($file, $lfile) = $self->print_latex(@_); + my $pdf = generate_pdf($file); + unlink($lfile); + + $pdf; +} + +=item print_html [ TIME [ , TEMPLATE [ , CID ] ] ] + +Returns an HTML invoice, as a scalar. + +TIME an optional value used to control the printing of overdue messages. The +default is now. It isn't the date of the invoice; that's the `_date' field. +It is specified as a UNIX timestamp; see L. Also see +L and L for conversion functions. + +CID is a MIME Content-ID used to create a "cid:" URL for the logo image, used +when emailing the invoice as part of a multipart/related MIME email. + +=cut + +sub print_html { + my( $self, $today, $template, $cid ) = @_; + + my %params = ( 'format' => 'html' ); + $params{'time'} = $today if $today; + $params{'template'} = $template if $template; + $params{'cid'} = $cid if $cid; + + $self->print_generic( %params ); +} + +# quick subroutine for print_latex +# +# There are ten characters that LaTeX treats as special characters, which +# means that they do not simply typeset themselves: +# # $ % & ~ _ ^ \ { } +# +# TeX ignores blanks following an escaped character; if you want a blank (as +# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). + +sub _latex_escape { + my $value = shift; + $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge; + $value =~ s/([<>])/\$$1\$/g; + $value; +} + +#utility methods for print_* + +sub _translate_old_latex_format { + warn "_translate_old_latex_format called\n" + if $DEBUG; + + my @template = (); + while ( @_ ) { + my $line = shift; + + if ( $line =~ /^%%Detail\s*$/ ) { + + push @template, q![@--!, + q! foreach my $_tr_line (@detail_items) {!, + q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!, + q! $_tr_line->{'description'} .= !, + q! "\\tabularnewline\n~~".!, + q! join( "\\tabularnewline\n~~",!, + q! @{$_tr_line->{'ext_description'}}!, + q! );!, + q! }!; + + while ( ( my $line_item_line = shift ) + !~ /^%%EndDetail\s*$/ ) { + $line_item_line =~ s/'/\\'/g; # nice LTS + $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes + $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g; + push @template, " \$OUT .= '$line_item_line';"; + } + + push @template, '}', + '--@]'; + + } elsif ( $line =~ /^%%TotalDetails\s*$/ ) { + + push @template, '[@--', + ' foreach my $_tr_line (@total_items) {'; + + while ( ( my $total_item_line = shift ) + !~ /^%%EndTotalDetails\s*$/ ) { + $total_item_line =~ s/'/\\'/g; # nice LTS + $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes + $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g; + push @template, " \$OUT .= '$total_item_line';"; + } + + push @template, '}', + '--@]'; + + } else { + $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g; + push @template, $line; + } + + } + + if ($DEBUG) { + warn "$_\n" foreach @template; + } + + (@template); +} + +sub terms { + my $self = shift; + + #check for an invoice- specific override (eventually) + + #check for a customer- specific override + return $self->cust_main->invoice_terms + if $self->cust_main->invoice_terms; + + #use configured default or default default + $conf->config('invoice_default_terms') || 'Payable upon receipt'; +} + +sub due_date { + my $self = shift; + my $duedate = ''; + if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) { + $duedate = $self->_date() + ( $1 * 86400 ); + } + $duedate; +} + +sub due_date2str { + my $self = shift; + $self->due_date ? time2str(shift, $self->due_date) : ''; +} + +sub balance_due_msg { + my $self = shift; + my $msg = 'Balance Due'; + return $msg unless $self->terms; + if ( $self->due_date ) { + $msg .= ' - Please pay by '. $self->due_date2str('%x'); + } elsif ( $self->terms ) { + $msg .= ' - '. $self->terms; + } + $msg; +} + +=item invnum_date_pretty + +Returns a string with the invoice number and date, for example: +"Invoice #54 (3/20/2008)" + +=cut + +sub invnum_date_pretty { + my $self = shift; + 'Invoice #'. $self->invnum. ' ('. time2str('%x', $self->_date). ')'; +} + +sub _items_sections { + my $self = shift; + + my %s = (); + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + + if ( $cust_bill_pkg->pkgnum > 0 ) { + + my $desc = $cust_bill_pkg->part_pkg->categoryname; + + $s{$desc} += $cust_bill_pkg->setup + if ( $cust_bill_pkg->setup != 0 ); + + $s{$desc} += $cust_bill_pkg->recur + if ( $cust_bill_pkg->recur != 0 ); + + } + + } + + map { {'description' => $_, 'subtotal' => $s{$_}} } sort keys %s; + +} + +sub _items { + my $self = shift; + + #my @display = scalar(@_) + # ? @_ + # : qw( _items_previous _items_pkg ); + # #: qw( _items_pkg ); + # #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments ); + my @display = qw( _items_previous _items_pkg ); + + my @b = (); + foreach my $display ( @display ) { + push @b, $self->$display(@_); + } + @b; +} + +sub _items_previous { + my $self = shift; + my $cust_main = $self->cust_main; + my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance + my @b = (); + foreach ( @pr_cust_bill ) { + push @b, { + 'description' => 'Previous Balance, Invoice #'. $_->invnum. + ' ('. time2str('%x',$_->_date). ')', + #'pkgpart' => 'N/A', + 'pkgnum' => 'N/A', + 'amount' => sprintf("%.2f", $_->owed), + }; + } + @b; + + #{ + # 'description' => 'Previous Balance', + # #'pkgpart' => 'N/A', + # 'pkgnum' => 'N/A', + # 'amount' => sprintf("%10.2f", $pr_total ), + # 'ext_description' => [ map { + # "Invoice ". $_->invnum. + # " (". time2str("%x",$_->_date). ") ". + # sprintf("%10.2f", $_->owed) + # } @pr_cust_bill ], + + #}; +} + +sub _items_pkg { + my $self = shift; + my %options = @_; + my $section = delete $options{'section'}; + my @cust_bill_pkg = + grep { $_->pkgnum && + ( defined($section) + ? $_->part_pkg->categoryname eq $section->{'description'} + : 1 + ) + } $self->cust_bill_pkg; + $self->_items_cust_bill_pkg(\@cust_bill_pkg, %options); +} + +sub _taxsort { + return 0 unless $a cmp $b; + return -1 if $b eq 'Tax'; + return 1 if $a eq 'Tax'; + return -1 if $b eq 'Other surcharges'; + return 1 if $a eq 'Other surcharges'; + $a cmp $b; +} + +sub _items_tax { + my $self = shift; + my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg; + $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); +} + +sub _items_cust_bill_pkg { + my $self = shift; + my $cust_bill_pkg = shift; + my %opt = @_; + + my $format = $opt{format} || ''; + my $escape_function = $opt{escape_function} || sub { shift }; + + my @b = (); + foreach my $cust_bill_pkg ( @$cust_bill_pkg ) { + + my $cust_pkg = $cust_bill_pkg->cust_pkg; + + my $desc = $cust_bill_pkg->desc; + + my %details_opt = ( 'format' => $format, + 'escape_function' => $escape_function, + ); + + if ( $cust_bill_pkg->pkgnum > 0 ) { + + if ( $cust_bill_pkg->setup != 0 ) { + + my $description = $desc; + $description .= ' Setup' if $cust_bill_pkg->recur != 0; + + my @d = map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date); + push @d, $cust_bill_pkg->details(%details_opt) + if $cust_bill_pkg->recur == 0; + + push @b, { + description => $description, + #pkgpart => $part_pkg->pkgpart, + pkgnum => $cust_bill_pkg->pkgnum, + amount => sprintf("%.2f", $cust_bill_pkg->setup), + unit_amount => sprintf("%.2f", $cust_bill_pkg->unitsetup), + quantity => $cust_bill_pkg->quantity, + ext_description => \@d, + }; + } + + if ( $cust_bill_pkg->recur != 0 ) { + + my $description = $desc; + unless ( $conf->exists('disable_line_item_date_ranges') ) { + $desc .= " (" . time2str("%x", $cust_bill_pkg->sdate). + " - ". time2str("%x", $cust_bill_pkg->edate). ")"; + } + + #at least until cust_bill_pkg has "past" ranges in addition to + #the "future" sdate/edate ones... see #3032 + my @d = map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date); + #$cust_bill_pkg->edate, + #$cust_bill_pkg->sdate), + push @d, $cust_bill_pkg->details(%details_opt); + + push @b, { + description => $description, + #pkgpart => $part_pkg->pkgpart, + pkgnum => $cust_bill_pkg->pkgnum, + amount => sprintf("%.2f", $cust_bill_pkg->recur), + unit_amount => sprintf("%.2f", $cust_bill_pkg->unitrecur), + quantity => $cust_bill_pkg->quantity, + ext_description => \@d, + }; + + } + + } else { #pkgnum tax or one-shot line item (??) + + if ( $cust_bill_pkg->setup != 0 ) { + push @b, { + 'description' => $desc, + 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), + }; + } + if ( $cust_bill_pkg->recur != 0 ) { + push @b, { + 'description' => "$desc (". + time2str("%x", $cust_bill_pkg->sdate). ' - '. + time2str("%x", $cust_bill_pkg->edate). ')', + 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), + }; + } + + } + + } + + @b; + +} + +sub _items_credits { + my $self = shift; + + my @b; + #credits + foreach ( $self->cust_credited ) { + + #something more elaborate if $_->amount ne $_->cust_credit->credited ? + + my $reason = $_->cust_credit->reason; + #my $reason = substr($_->cust_credit->reason,0,32); + #$reason .= '...' if length($reason) < length($_->cust_credit->reason); + $reason = " ($reason) " if $reason; + push @b, { + #'description' => 'Credit ref\#'. $_->crednum. + # " (". time2str("%x",$_->cust_credit->_date) .")". + # $reason, + 'description' => 'Credit applied '. + time2str("%x",$_->cust_credit->_date). $reason, + 'amount' => sprintf("%.2f",$_->amount), + }; + } + #foreach ( @cr_cust_credit ) { + # push @buf,[ + # "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", + # $money_char. sprintf("%10.2f",$_->credited) + # ]; + #} + + @b; + +} + +sub _items_payments { + my $self = shift; + + my @b; + #get & print payments + foreach ( $self->cust_bill_pay ) { + + #something more elaborate if $_->amount ne ->cust_pay->paid ? + + push @b, { + 'description' => "Payment received ". + time2str("%x",$_->cust_pay->_date ), + 'amount' => sprintf("%.2f", $_->amount ) + }; + } + + @b; + +} + + +=back + +=head1 SUBROUTINES + +=over 4 + +=item reprint + +=cut + +sub process_reprint { + process_re_X('print', @_); +} + +=item reemail + +=cut + +sub process_reemail { + process_re_X('email', @_); +} + +=item refax + +=cut + +sub process_refax { + process_re_X('fax', @_); +} + +use Storable qw(thaw); +use Data::Dumper; +use MIME::Base64; +sub process_re_X { + my( $method, $job ) = ( shift, shift ); + warn "$me process_re_X $method for job $job\n" if $DEBUG; + + my $param = thaw(decode_base64(shift)); + warn Dumper($param) if $DEBUG; + + re_X( + $method, + $job, + %$param, + ); + +} + +sub re_X { + my($method, $job, %param ) = @_; + if ( $DEBUG ) { + warn "re_X $method for job $job with param:\n". + join( '', map { " $_ => ". $param{$_}. "\n" } keys %param ); + } + + #some false laziness w/search/cust_bill.html + my $distinct = ''; + my $orderby = 'ORDER BY cust_bill._date'; + + my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param); + + my $addl_from = 'LEFT JOIN cust_main USING ( custnum )'; + + my @cust_bill = qsearch( { + #'select' => "cust_bill.*", + 'table' => 'cust_bill', + 'addl_from' => $addl_from, + 'hashref' => {}, + 'extra_sql' => $extra_sql, + 'order_by' => $orderby, + 'debug' => 1, + } ); + + warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n" + if $DEBUG; + + my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo + foreach my $cust_bill ( @cust_bill ) { + $cust_bill->$method(); + + if ( $job ) { #progressbar foo + $num++; + if ( time - $min_sec > $last ) { + my $error = $job->update_statustext( + int( 100 * $num / scalar(@cust_bill) ) + ); + die $error if $error; + $last = time; + } + } + + } + +} + +=back + +=head1 CLASS METHODS + +=over 4 + +=item owed_sql + +Returns an SQL fragment to retreive the amount owed (charged minus credited and paid). + +=cut + +sub owed_sql { + my $class = shift; + 'charged - '. $class->paid_sql. ' - '. $class->credited_sql; +} + +=item net_sql + +Returns an SQL fragment to retreive the net amount (charged minus credited). + +=cut + +sub net_sql { + my $class = shift; + 'charged - '. $class->credited_sql; +} + +=item paid_sql + +Returns an SQL fragment to retreive the amount paid against this invoice. + +=cut + +sub paid_sql { + #my $class = shift; + "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay + WHERE cust_bill.invnum = cust_bill_pay.invnum )"; +} + +=item credited_sql + +Returns an SQL fragment to retreive the amount credited against this invoice. + +=cut + +sub credited_sql { + #my $class = shift; + "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill + WHERE cust_bill.invnum = cust_credit_bill.invnum )"; +} + +=item search_sql HASHREF + +Class method which returns an SQL WHERE fragment to search for parameters +specified in HASHREF. Valid parameters are + +=over 4 + +=item begin + +Epoch date (UNIX timestamp) setting a lower bound for _date values + +=item end + +Epoch date (UNIX timestamp) setting an upper bound for _date values + +=item invnum_min + +=item invnum_max + +=item agentnum + +=item owed + +=item net + +=item days + +=item newest_percust + +=back + +Note: validates all passed-in data; i.e. safe to use with unchecked CGI params. + +=cut + +sub search_sql { + my($class, $param) = @_; + if ( $DEBUG ) { + warn "$me search_sql called with params: \n". + join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n"; + } + + my @search = (); + + if ( $param->{'begin'} =~ /^(\d+)$/ ) { + push @search, "cust_bill._date >= $1"; + } + if ( $param->{'end'} =~ /^(\d+)$/ ) { + push @search, "cust_bill._date < $1"; + } + if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) { + push @search, "cust_bill.invnum >= $1"; + } + if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) { + push @search, "cust_bill.invnum <= $1"; + } + if ( $param->{'agentnum'} =~ /^(\d+)$/ ) { + push @search, "cust_main.agentnum = $1"; + } + + push @search, '0 != '. FS::cust_bill->owed_sql + if $param->{'open'}; + + push @search, '0 != '. FS::cust_bill->net_sql + if $param->{'net'}; + + push @search, "cust_bill._date < ". (time-86400*$param->{'days'}) + if $param->{'days'}; + + if ( $param->{'newest_percust'} ) { + + #$distinct = 'DISTINCT ON ( cust_bill.custnum )'; + #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC'; + + my @newest_where = map { my $x = $_; + $x =~ s/\bcust_bill\./newest_cust_bill./g; + $x; + } + grep ! /^cust_main./, @search; + my $newest_where = scalar(@newest_where) + ? ' AND '. join(' AND ', @newest_where) + : ''; + + + push @search, "cust_bill._date = ( + SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill + WHERE newest_cust_bill.custnum = cust_bill.custnum + $newest_where + )"; + + } + + my $curuser = $FS::CurrentUser::CurrentUser; + if ( $curuser->username eq 'fs_queue' + && $param->{'CurrentUser'} =~ /^(\w+)$/ ) { + my $username = $1; + my $newuser = qsearchs('access_user', { + 'username' => $username, + 'disabled' => '', + } ); + if ( $newuser ) { + $curuser = $newuser; + } else { + warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n"; + } + } + + push @search, $curuser->agentnums_sql; + + join(' AND ', @search ); + +} + +=back + +=head1 BUGS + +The delete method. =head1 SEE ALSO