X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=d55eb438f87b4fa316e685bb74c1e878eb816e41;hb=05686487551e26418c9b2d6b92ea0d89bb100082;hp=b9a99c9007a6881de349dc5fbf9cffa5df964e4b;hpb=bacfb7f2c7c4ab53f5496603ebf95224e50fadb1;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index b9a99c900..d55eb438f 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,8 +1,10 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $DEBUG $conf $money_char ); +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 IPC::Run3; use Date::Format; use Text::Template 1.20; @@ -11,23 +13,28 @@ use String::ShellQuote; use HTML::Entities; use Locale::Country; use FS::UID qw( datasrc ); -use FS::Record qw( qsearch qsearchs ); use FS::Misc qw( send_email send_fax ); +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::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 ); $DEBUG = 0; +$me = '[FS::cust_bill]'; #ask FS::UID to run this stuff for us later FS::UID->install_callback( sub { @@ -105,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, @@ -112,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 @@ -133,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 @@ -203,6 +229,47 @@ 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 @@ -227,6 +294,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. @@ -326,6 +412,79 @@ sub owed { $balance; } +=item apply_payments_and_credits + +=cut + +sub apply_payments_and_credits { + my $self = shift; + + 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 { $_->cust_pkg->part_pkg->pay_weight || 0 } + @open_lineitems + ); + my $max_credit_weight = + max( map { $_->cust_pkg->part_pkg->credit_weight || 0 } + @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; + die $error if $error; + + } + +} =item generate_email PARAMHASH @@ -417,7 +576,7 @@ sub generate_email { my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc"; my $file; - if ( length($args{'_template'}) + if ( defined($args{'_template'}) && length($args{'_template'}) && -e "$path/logo_". $args{'_template'}. ".png" ) { @@ -719,7 +878,7 @@ sub send_if_newest { $self->send(@_); } -=item send_csv OPTIONS +=item send_csv OPTION => VALUE, ... Sends invoice as a CSV data-file to a remote host with the specified protocol. @@ -734,7 +893,148 @@ dir The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number and YYMMDDHHMMSS is a timestamp. -The fields of the CSV file is as follows: +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 @@ -742,13 +1042,13 @@ record_type, invnum, custnum, _date, charged, first, last, company, address1, ad =item record type - B is either C or C -If B is C, this is a primary invoice record. The +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. -If B is C, this is a line item record. Only the -first two fields (B and B) and the last five fields -(B through B) 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 @@ -788,101 +1088,213 @@ first two fields (B and B) and the last five fields =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 send_csv { +sub print_csv { my($self, %opt) = @_; + + eval "use Text::CSV_XS"; + die $@ if $@; - #part one: create file + my $cust_main = $self->cust_main; - my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; - mkdir $spooldir, 0700 unless -d $spooldir; + my $csv = Text::CSV_XS->new({'always_quote'=>1}); - my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); + if ( lc($opt{'format'}) eq 'billco' ) { - open(CSV, ">$file") or die "can't open $file: $!"; + my $taxtotal = 0; + $taxtotal += $_->{'amount'} foreach $self->_items_tax; - eval "use Text::CSV_XS"; - die $@ if $@; + my $duedate = ''; + if ( $conf->exists('invoice_default_terms') + && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) { + $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) ); + } - my $csv = Text::CSV_XS->new({'always_quote'=>1}); + my( $previous_balance, @unused ) = $self->previous; #previous balance - my $cust_main = $self->cust_main; + my $pmt_cr_applied = 0; + $pmt_cr_applied += $_->{'amount'} + foreach ( $self->_items_payments, $self->_items_credits ) ; - $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"; - print CSV $csv->string. "\n"; - - #new charges (false laziness w/print_text and _items stuff) - 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->cust_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 ) - : '' ), - time2str("%x", $cust_bill_pkg->sdate), - time2str("%x", $cust_bill_pkg->edate), - ); + my $totaldue = sprintf('%.2f', $self->owed + $previous_balance); - } 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( + '', # 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_pkg', + 'cust_bill', $self->invnum, - ( map { '' } (1..11) ), - ($pkg, $setup, $recur, $sdate, $edate) + $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"; - print CSV $csv->string. "\n"; - } - close CSV or die "can't close 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 + ); - #part two: upload it + $detail .= $csv->string. "\n"; - 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: $@"; + } else { - $net->binary or die "can't set binary mode"; + 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->cust_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"; - $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}"; + $detail .= $csv->string. "\n"; - $net->put($file) or die "can't put $file: $!"; + } - $net->quit; + } - unlink $file; + ( $header, $detail ); } @@ -979,36 +1391,118 @@ sub realtime_bop { } -=item batch_card +=item batch_card OPTION => VALUE... Adds a payment for this invoice to the pending credit card batch (see -L). +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 = shift; + my ($self, %options) = @_; my $cust_main = $self->cust_main; + my $amount = sprintf("%.2f", $cust_main->balance - $cust_main->in_transit_payments); + return '' unless $amount > 0; + + if ($options{'realtime'}) { + return $cust_main->realtime_bop( FS::payby->payby2bop($cust_main->payby), + $amount, + %options, + ); + } + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE") + or return "Cannot lock pay_batch: " . $dbh->errstr; + + my %pay_batch = ( + 'status' => 'O', + 'payby' => FS::payby->payby2payment($cust_main->payby), + ); + + my $pay_batch = qsearchs( 'pay_batch', \%pay_batch ); + + unless ( $pay_batch ) { + $pay_batch = new FS::pay_batch \%pay_batch; + my $error = $pay_batch->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + die "error creating new batch: $error\n"; + } + } + + my $old_cust_pay_batch = qsearchs('cust_pay_batch', { + 'batchnum' => $pay_batch->batchnum, + 'custnum' => $cust_main->custnum, + } ); + my $cust_pay_batch = new FS::cust_pay_batch ( { - 'invnum' => $self->getfield('invnum'), - 'custnum' => $cust_main->getfield('custnum'), + 'batchnum' => $pay_batch->batchnum, + 'invnum' => $self->getfield('invnum'), # is there a better value? + # this field should be + # removed... + # cust_bill_pay_batch now + 'custnum' => $cust_main->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'), - 'cardnum' => $cust_main->payinfo, - 'exp' => $cust_main->getfield('paydate'), - 'payname' => $cust_main->getfield('payname'), - 'amount' => $self->owed, + 'address1' => $cust_main->address1, + 'address2' => $cust_main->address2, + 'city' => $cust_main->city, + 'state' => $cust_main->state, + 'zip' => $cust_main->zip, + 'country' => $cust_main->country, + 'payby' => $cust_main->payby, + 'payinfo' => $cust_main->payinfo, + 'exp' => $cust_main->paydate, + 'payname' => $cust_main->payname, + 'amount' => $amount, # consolidating } ); - my $error = $cust_pay_batch->insert; - die $error if $error; + + $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum) + if $old_cust_pay_batch; + my $error; + if ($old_cust_pay_batch) { + $error = $cust_pay_batch->replace($old_cust_pay_batch) + } else { + $error = $cust_pay_batch->insert; + } + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + die $error; + } + + my $unapplied = $cust_main->total_credited + $cust_main->total_unapplied_payments + $cust_main->in_transit_payments; + foreach my $cust_bill ($cust_main->open_cust_bill) { + #$dbh->commit or die $dbh->errstr if $oldAutoCommit; + my $cust_bill_pay_batch = new FS::cust_bill_pay_batch { + 'invnum' => $cust_bill->invnum, + 'paybatchnum' => $cust_pay_batch->paybatchnum, + 'amount' => $cust_bill->owed, + '_date' => time, + }; + if ($unapplied >= $cust_bill_pay_batch->amount){ + $unapplied -= $cust_bill_pay_batch->amount; + next; + }else{ + $cust_bill_pay_batch->amount(sprintf ( "%.2f", + $cust_bill_pay_batch->amount - $unapplied )); + $unapplied = 0; + } + $error = $cust_bill_pay_batch->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + die $error; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; } @@ -1222,12 +1716,14 @@ sub print_text { #setup template variables package FS::cust_bill::_template; #! - use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent ); + use vars qw( $custnum $invnum $date $agent @address $overdue + $page $total_pages @buf ); + $custnum = $self->custnum; $invnum = $self->invnum; $date = $self->_date; - $page = 1; $agent = $self->cust_main->agent->agent; + $page = 1; if ( $FS::cust_bill::invoice_lines ) { $total_pages = @@ -1307,7 +1803,7 @@ L and L for conversion functions. =cut -#still some false laziness w/print_text (mostly print_text should use _items stuff though) +#still some false laziness w/print_text and print_html (and send_csv) (mostly print_text should use _items stuff though) sub print_latex { my( $self, $today, $template ) = @_; @@ -1360,6 +1856,7 @@ sub print_latex { } my %invoice_data = ( + 'custnum' => $self->custnum, 'invnum' => $self->invnum, 'date' => time2str('%b %o, %Y', $self->_date), 'today' => time2str('%b %o, %Y', $today), @@ -1734,6 +2231,7 @@ when emailing the invoice as part of a multipart/related MIME email. =cut +#some falze laziness w/print_text and print_latex (and send_csv) sub print_html { my( $self, $today, $template, $cid ) = @_; $today ||= time; @@ -1759,6 +2257,7 @@ sub print_html { or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR; my %invoice_data = ( + 'custnum' => $self->custnum, 'invnum' => $self->invnum, 'date' => time2str('%b %o, %Y', $self->_date), 'today' => time2str('%b %o, %Y', $today), @@ -1777,17 +2276,25 @@ sub print_html { # 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc", ); - $invoice_data{'returnaddress'} = - length( $conf->config_orbase('invoice_htmlreturnaddress', $template) ) - ? join("\n", $conf->config('invoice_htmlreturnaddress', $template) ) - : join("\n", map { - s/~/ /g; - s/\\\\\*?\s*$/
/; - s/\\hyphenation\{[\w\s\-]+\}//; - $_; - } - $conf->config_orbase('invoice_latexreturnaddress', $template) - ); + if ( + defined( $conf->config_orbase('invoice_htmlreturnaddress', $template) ) + && length( $conf->config_orbase('invoice_htmlreturnaddress', $template) ) + ) { + $invoice_data{'returnaddress'} = + join("\n", $conf->config('invoice_htmlreturnaddress', $template) ); + } else { + $invoice_data{'returnaddress'} = + join("\n", map { + s/~/ /g; + s/\\\\\*?\s*$/
/; + s/\\hyphenation\{[\w\s\-]+\}//; + $_; + } + $conf->config_orbase( 'invoice_latexreturnaddress', + $template + ) + ); + } my $countrydefault = $conf->config('countrydefault') || 'US'; if ( $cust_main->country eq $countrydefault ) { @@ -1797,20 +2304,26 @@ sub print_html { encode_entities(code2country($cust_main->country)); } - $invoice_data{'notes'} = - length($conf->config_orbase('invoice_htmlnotes', $template)) - ? join("\n", $conf->config_orbase('invoice_htmlnotes', $template) ) - : join("\n", map { - s/%%(.*)$//; - s/\\section\*\{\\textsc\{(.)(.*)\}\}/

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

    /; - s/\\item /
  1. /; - s/\\end\{enumerate\}/<\/ol>/; - s/\\textbf\{(.*)\}/$1<\/b>/; - $_; - } - $conf->config_orbase('invoice_latexnotes', $template) - ); + if ( + defined( $conf->config_orbase('invoice_htmlnotes', $template) ) + && length( $conf->config_orbase('invoice_htmlnotes', $template) ) + ) { + $invoice_data{'notes'} = + join("\n", $conf->config_orbase('invoice_htmlnotes', $template) ); + } else { + $invoice_data{'notes'} = + join("\n", map { + s/%%(.*)$//; + s/\\section\*\{\\textsc\{(.)(.*)\}\}/

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

      /; + s/\\item /
    1. /; + s/\\end\{enumerate\}/<\/ol>/; + s/\\textbf\{(.*)\}/$1<\/b>/; + $_; + } + $conf->config_orbase('invoice_latexnotes', $template) + ); + } # #do variable substitutions in notes # $invoice_data{'notes'} = @@ -1819,12 +2332,18 @@ sub print_html { # $conf->config_orbase('invoice_latexnotes', $suffix) # ); + if ( + defined( $conf->config_orbase('invoice_htmlfooter', $template) ) + && length( $conf->config_orbase('invoice_htmlfooter', $template) ) + ) { $invoice_data{'footer'} = - length($conf->config_orbase('invoice_htmlfooter', $template)) - ? join("\n", $conf->config_orbase('invoice_htmlfooter', $template) ) - : join("\n", map { s/~/ /g; s/\\\\\*?\s*$/
      /; $_; } - $conf->config_orbase('invoice_latexfooter', $template) - ); + join("\n", $conf->config_orbase('invoice_htmlfooter', $template) ); + } else { + $invoice_data{'footer'} = + join("\n", map { s/~/ /g; s/\\\\\*?\s*$/
      /; $_; } + $conf->config_orbase('invoice_latexfooter', $template) + ); + } $invoice_data{'po_line'} = ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) @@ -2113,6 +2632,7 @@ sub _items_payments { } + =back =head1 SUBROUTINES @@ -2148,6 +2668,7 @@ use Data::Dumper; use MIME::Base64; sub process_re_X { my( $method, $job ) = ( shift, shift ); + warn "process_re_X $method for job $job\n" if $DEBUG; my $param = thaw(decode_base64(shift)); warn Dumper($param) if $DEBUG; @@ -2163,6 +2684,10 @@ sub process_re_X { sub re_X { my($method, $job, %param ) = @_; # [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ], + 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 = '';