X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=f10a5d0c42226818e45a8c35dc735be9cc353536;hb=08b36523ebbf6e2995878f26bfac988f32f7a218;hp=2df92f75f4f183c17c258ba82fc24ce9e9ec6b8f;hpb=4063430b29ef2c83da42772402249f02f8004327;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 2df92f75f..f10a5d0c4 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,37 +1,52 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $conf $invoice_template ); +use vars qw( @ISA $DEBUG $me $conf $money_char $date_format $rdate_format ); use vars qw( $invoice_lines @buf ); #yuck +use Fcntl qw(:flock); #for spool_csv +use List::Util qw(min max); use Date::Format; -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 Storable qw( freeze thaw ); +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_statement; use FS::cust_bill_pkg; +use FS::cust_bill_pkg_display; +use FS::cust_bill_pkg_detail; 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 ); + +$DEBUG = 0; +$me = '[FS::cust_bill]'; #ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_bill'} = sub { +FS::UID->install_callback( sub { $conf = new FS::Conf; - my @invoice_template = $conf->config('invoice_template') - or die "cannot load config file invoice_template"; - $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; - $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"; -}; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; + $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; +} ); =head1 NAME @@ -60,13 +75,19 @@ FS::cust_bill - Object methods for cust_bill records @cust_pay_objects = $cust_bill->cust_pay; + $tax_amount = $record->tax; + @lines = $cust_bill->print_text; @lines = $cust_bill->print_text $time; =head1 DESCRIPTION -An FS::cust_bill object represents an invoice. FS::cust_bill inherits from -FS::Record. The following fields are currently supported: +An FS::cust_bill object represents an invoice; a declaration that a customer +owes you money. The specific charges are itemized as B records +(see L). FS::cust_bill inherits from FS::Record. The +following fields are currently supported: + +Regular fields =over 4 @@ -79,11 +100,37 @@ L and L for conversion functions. =item charged - amount of this invoice -=item owed - amount still outstanding on this invoice, which is charged minus -all payments (see L). +=item invoice_terms - optional terms override for this specific invoice + +=back + +Customer info at invoice generation time + +=over 4 + +=item previous_balance + +=item billing_balance + +=back + +Deprecated + +=over 4 + +=item printed - deprecated + +=back + +Specific use cases + +=over 4 + +=item closed - books closed flag, empty or `Y' + +=item statementnum - invoice aggregation (see L) -=item printed - how many times this invoice has been printed automatically -(see L). +=item agent_invid - legacy invoice number =back @@ -101,35 +148,78 @@ 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, returns the error, otherwise returns false. -When adding new invoices, owed must be charged (or null, in which case it is -automatically set to charged). +=item delete + +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 -sub insert { +sub delete { my $self = shift; + return "Can't delete closed invoice" if $self->closed =~ /^Y/i; - $self->owed( $self->charged ) if $self->owed eq ''; - return "owed != charged!" - unless $self->owed == $self->charged; + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; - $self->SUPER::insert; -} + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; -=item delete + foreach my $table (qw( + cust_bill_event + cust_event + cust_credit_bill + cust_bill_pay + cust_bill_pay + cust_credit_bill + cust_pay_batch + cust_bill_pay_batch + cust_bill_pkg + )) { -Currently unimplemented. I don't remove invoices because there would then be -no record you ever posted this invoice (which is bad, no?) + foreach my $linked ( $self->$table() ) { + my $error = $linked->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } -=cut + } + + my $error = $self->SUPER::delete(@_); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + ''; -sub delete { - return "Can't remove invoice!" } =item replace OLD_RECORD @@ -137,21 +227,25 @@ sub delete { Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. -Only owed and printed may be changed. Owed is normally updated by creating and -inserting a payment (see L). Printed is normally updated by -calling the collect method of a customer object (see L). +Only printed may be changed. printed is normally updated by calling the +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 "(New) owed can't be > (new) charged!" if $new->owed > $new->charged; + return "Can't change charged!" unless $old->charged == $new->charged + || $old->charged == 0; - $new->SUPER::replace($old); + ''; } =item check @@ -167,22 +261,37 @@ sub check { my $error = $self->ut_numbern('invnum') - || $self->ut_number('custnum') + || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' ) || $self->ut_numbern('_date') || $self->ut_money('charged') - || $self->ut_money('owed') || $self->ut_numbern('printed') + || $self->ut_enum('closed', [ '', 'Y' ]) + || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' ) + || $self->ut_numbern('agent_invid') #varchar? ; return $error if $error; - return "Unknown customer" - unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); - $self->_date(time) unless $self->_date; $self->printed(0) if $self->printed eq ''; - ''; #no error + $self->SUPER::check; +} + +=item display_invnum + +Returns the displayed invoice number for this invoice: agent_invid if +cust_bill-default_agent_invid is set and it has a value, invnum otherwise. + +=cut + +sub display_invnum { + my $self = shift; + if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){ + return $self->agent_invid; + } else { + return $self->invnum; + } } =item previous @@ -211,235 +320,4234 @@ Returns the line items (see L) for this invoice. sub cust_bill_pkg { my $self = shift; - qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } ); + qsearch( + { 'table' => 'cust_bill_pkg', + 'hashref' => { 'invnum' => $self->invnum }, + 'order_by' => 'ORDER BY billpkgnum', + } + ); +} + +=item cust_bill_pkg_pkgnum PKGNUM + +Returns the line items (see L) for this invoice and +specified pkgnum. + +=cut + +sub cust_bill_pkg_pkgnum { + my( $self, $pkgnum ) = @_; + qsearch( + { 'table' => 'cust_bill_pkg', + 'hashref' => { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + }, + 'order_by' => 'ORDER BY billpkgnum', + } + ); +} + +=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 { $_->pkgnum > 0 ? $_->cust_pkg : () } + $self->cust_bill_pkg; + my %saw = (); + grep { ! $saw{$_->pkgnum}++ } @cust_pkg; +} + +=item no_auto + +Returns true if any of the packages (or their definitions) corresponding to the +line items for this invoice have the no_auto flag set. + +=cut + +sub no_auto { + my $self = shift; + grep { $_->no_auto || $_->part_pkg->no_auto } $self->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 (deprecated, old-style events - see L) for this invoice. + +=cut + +sub cust_bill_event { + my $self = shift; + 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 + +Returns the customer (see L) for this invoice. + +=cut + +sub cust_main { + my $self = shift; + 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 -Returns a list consisting of the total previous credited (see -L) for this customer, followed by the previous outstanding -credits (FS::cust_credit objects). +Depreciated. See the cust_credited method. + + #Returns a list consisting of the total previous credited (see + #L) and unapplied for this customer, followed by the previous + #outstanding credits (FS::cust_credit objects). =cut sub cust_credit { - my $self = shift; - my $total = 0; - my @cust_credit = sort { $a->_date <=> $b->date } - grep { $_->credited != 0 && $_->_date < $self->_date } - qsearch('cust_credit', { 'custnum' => $self->custnum } ) - ; - foreach (@cust_credit) { $total += $_->credited; } - $total, @cust_credit; + use Carp; + croak "FS::cust_bill->cust_credit depreciated; see ". + "FS::cust_bill->cust_credit_bill"; + #my $self = shift; + #my $total = 0; + #my @cust_credit = sort { $a->_date <=> $b->_date } + # grep { $_->credited != 0 && $_->_date < $self->_date } + # qsearch('cust_credit', { 'custnum' => $self->custnum } ) + #; + #foreach (@cust_credit) { $total += $_->credited; } + #$total, @cust_credit; } =item cust_pay -Returns all payments (see L) for this invoice. +Depreciated. See the cust_bill_pay method. + +#Returns all payments (see L) for this invoice. =cut sub cust_pay { + use Carp; + croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay"; + #my $self = shift; + #sort { $a->_date <=> $b->_date } + # qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) + #; +} + +sub cust_pay_batch { + my $self = shift; + qsearch('cust_pay_batch', { 'invnum' => $self->invnum } ); +} + +sub cust_bill_pay_batch { my $self = shift; - sort { $a->_date <=> $b->date } - qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) + qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } ); +} + +=item cust_bill_pay + +Returns all payment applications (see L) for this invoice. + +=cut + +sub cust_bill_pay { + my $self = shift; + map { $_ } #return $self->num_cust_bill_pay unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } ); +} + +=item cust_credited + +=item cust_credit_bill + +Returns all applied credits (see L) for this invoice. + +=cut + +sub cust_credited { + my $self = shift; + map { $_ } #return $self->num_cust_credit_bill unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } ) ; } -=item print_text [TIME]; +sub cust_credit_bill { + shift->cust_credited(@_); +} -Returns an text invoice, as a list of lines. +=item cust_bill_pay_pkgnum PKGNUM -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. +Returns all payment applications (see L) for this invoice +with matching pkgnum. =cut -sub print_text { +sub cust_bill_pay_pkgnum { + my( $self, $pkgnum ) = @_; + map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + } + ); +} - my( $self, $today ) = ( shift, shift ); - $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; +=item cust_credited_pkgnum PKGNUM - 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; +=item cust_credit_bill_pkgnum PKGNUM - # +Returns all applied credits (see L) for this invoice +with matching pkgnum. - #my @collect = (); - #my($description,$amount); - @buf = (); +=cut - #previous balance - foreach ( @pr_cust_bill ) { - push @buf, [ - "Previous Balance, Invoice #". $_->invnum. - " (". time2str("%x",$_->_date). ")", - '$'. sprintf("%10.2f",$_->owed) - ]; - } - if (@pr_cust_bill) { - push @buf,['','-----------']; - push @buf,['Total Previous Balance','$' . sprintf("%10.2f",$pr_total ) ]; - push @buf,['','']; - } +sub cust_credited_pkgnum { + my( $self, $pkgnum ) = @_; + map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + } + ); +} - #new charges - foreach ( $self->cust_bill_pkg ) { +sub cust_credit_bill_pkgnum { + shift->cust_credited_pkgnum(@_); +} - if ( $_->pkgnum ) { +=item tax - my($cust_pkg)=qsearchs('cust_pkg', { 'pkgnum', $_->pkgnum } ); - my($part_pkg)=qsearchs('part_pkg',{'pkgpart'=>$cust_pkg->pkgpart}); - my($pkg)=$part_pkg->pkg; +Returns the tax amount (see L) for this invoice. - if ( $_->setup != 0 ) { - push @buf, [ "$pkg Setup",'$' . sprintf("%10.2f",$_->setup) ]; - push @buf, - map { [ " ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels; - } +=cut - if ( $_->recur != 0 ) { - push @buf, [ - "$pkg (" . time2str("%x",$_->sdate) . " - " . - time2str("%x",$_->edate) . ")", - '$' . sprintf("%10.2f",$_->recur) - ]; - push @buf, - map { [ " ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels; - } +sub tax { + my $self = shift; + my $total = 0; + my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum , + 'pkgnum' => 0 } ); + foreach (@taxlines) { $total += $_->setup; } + $total; +} - } else { #pkgnum Tax - push @buf,["Tax",'$' . sprintf("%10.2f",$_->setup) ] - if $_->setup != 0; - } - } +=item owed - push @buf,['','-----------']; - push @buf,['Total New Charges', - '$' . sprintf("%10.2f",$self->charged) ]; - push @buf,['','']; +Returns the amount owed (still outstanding) on this invoice, which is charged +minus all payment applications (see L) and credit +applications (see L). - push @buf,['','-----------']; - push @buf,['Total Charges', - '$' . sprintf("%10.2f",$self->charged + $pr_total) ]; - push @buf,['','']; +=cut - #credits - foreach ( @cr_cust_credit ) { - push @buf,[ - "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", - '$' . sprintf("%10.2f",$_->credited) - ]; - } +sub owed { + my $self = shift; + my $balance = $self->charged; + $balance -= $_->amount foreach ( $self->cust_bill_pay ); + $balance -= $_->amount foreach ( $self->cust_credited ); + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} - #get & print payments - foreach ( $self->cust_pay ) { - push @buf,[ - "Payment received ". time2str("%x",$_->_date ), - '$' . sprintf("%10.2f",$_->paid ) - ]; - } +sub owed_pkgnum { + my( $self, $pkgnum ) = @_; - #balance due - push @buf,['','-----------']; - push @buf,['Balance Due','$' . - sprintf("%10.2f",$self->owed + $pr_total - $cr_total ) ]; + #my $balance = $self->charged; + my $balance = 0; + $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); - #setup template variables - - package FS::cust_bill::_template; #! - use vars qw( $invnum $date $page $total_pages @address $overdue @buf ); + $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); + $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); - $invnum = $self->invnum; - $date = $self->_date; - $page = 1; + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} - $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; +=item apply_payments_and_credits [ OPTION => VALUE ... ] +Applies unapplied payments and credits to this invoice. - #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'; - - #overdue? (variable for the template) - $FS::cust_bill::_template::overdue = ( - $balance_due > 0 - && $today > $self->_date -# && $self->printed > 1 - && $self->printed > 0 - ); +A hash of optional arguments may be passed. Currently "manual" is supported. +If true, a payment receipt is sent instead of a statement when +'payment_receipt_email' configuration option is set. - #and subroutine for the template +If there is an error, returns the error, otherwise returns false. - sub FS::cust_bill::_template::invoice_lines { - my $lines = shift; - map { - scalar(@buf) ? shift @buf : [ '', '' ]; - } - ( 1 .. $lines ); +=cut + +sub apply_payments_and_credits { + my( $self, %options ) = @_; + + 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; + + if ( $conf->exists('pkg-balances') ) { + # limit @payments & @credits to those w/ a pkgnum grepped from $self + my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg; + @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments; + @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits; } + + 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'; + } - $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++; + } elsif ( @payments ) { + $app = 'pay'; + } elsif ( @credits ) { + $app = 'credit'; + } else { + die "guru meditation #12 and 35"; + } + + my $unapp_amount; + if ( $app eq 'pay' ) { + + my $payment = shift @payments; + $unapp_amount = $payment->unapplied; + $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum }; + $app->pkgnum( $payment->pkgnum ) + if $conf->exists('pkg-balances') && $payment->pkgnum; + + } elsif ( $app eq 'credit' ) { + + my $credit = shift @credits; + $unapp_amount = $credit->credited; + $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum }; + $app->pkgnum( $credit->pkgnum ) + if $conf->exists('pkg-balances') && $credit->pkgnum; + + } else { + die "guru meditation #12 and 35"; + } + + my $owed; + if ( $conf->exists('pkg-balances') && $app->pkgnum ) { + warn "owed_pkgnum ". $app->pkgnum; + $owed = $self->owed_pkgnum($app->pkgnum); + } else { + $owed = $self->owed; + } + next unless $owed > 0; + + warn "min ( $unapp_amount, $owed )\n" if $DEBUG; + $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) ); + + $app->invnum( $self->invnum ); + + my $error = $app->insert(%options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting ". $app->table. " record: $error"; + } + die $error if $error; + } - map "$_\n", @collect; + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error } -=back +=item generate_email OPTION => VALUE ... -=head1 VERSION +Options: -$Id: cust_bill.pm,v 1.3 2000-09-20 10:35:21 ivan Exp $ +=over 4 -=head1 BUGS +=item from -The delete method. +sender address, required + +=item tempate + +alternate template name, optional + +=item print_text + +text attachment arrayref, optional + +=item subject + +email subject, optional + +=item notice_name + +notice name instead of "Invoice", optional + +=back + +Returns an argument list to be passed to L. + +=cut -print_text formatting (and some logic :/) is in source, but needs to be -slurped in from a file. Also number of lines ($=). +use MIME::Entity; -missing print_ps for a nice postscript copy (maybe HylaFAX-cover-page-style -or something similar so the look can be completely customized?) +sub generate_email { + + my $self = shift; + my %args = @_; + + my $me = '[FS::cust_bill::generate_email]'; + + my %return = ( + 'from' => $args{'from'}, + 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), + ); + + my %opt = ( + 'unsquelch_cdr' => $conf->exists('voip-cdr_email'), + 'template' => $args{'template'}, + 'notice_name' => ( $args{'notice_name'} || 'Invoice' ), + ); + + my $cust_main = $self->cust_main; + + if (ref($args{'to'}) eq 'ARRAY') { + $return{'to'} = $args{'to'}; + } else { + $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ } + $cust_main->invoicing_list + ]; + } + + if ( $conf->exists('invoice_html') ) { + + warn "$me creating HTML/text multipart message" + if $DEBUG; + + $return{'nobody'} = 1; + + my $alternative = build MIME::Entity + 'Type' => 'multipart/alternative', + 'Encoding' => '7bit', + 'Disposition' => 'inline' + ; + + my $data; + if ( $conf->exists('invoice_email_pdf') + and scalar($conf->config('invoice_email_pdf_note')) ) { + + warn "$me using 'invoice_email_pdf_note' in multipart message" + if $DEBUG; + $data = [ map { $_ . "\n" } + $conf->config('invoice_email_pdf_note') + ]; + + } 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(\%opt) ]; + } + + } + + $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 $logo; + my $agentnum = $cust_main->agentnum; + if ( defined($args{'template'}) && length($args{'template'}) + && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum ) + ) + { + $logo = 'logo_'. $args{'template'}. '.png'; + } else { + $logo = "logo.png"; + } + my $image_data = $conf->config_binary( $logo, $agentnum); + + my $image = build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $image_data, + 'Filename' => 'logo.png', + 'Content-ID' => "<$content_id>", + ; + + $alternative->attach( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', + 'Data' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + $self->print_html({ 'cid'=>$content_id, %opt }), + ' ', + '', + ], + 'Disposition' => 'inline', + #'Filename' => 'invoice.pdf', + ); + + my @otherparts = (); + if ( $cust_main->email_csv_cdr ) { + + push @otherparts, build MIME::Entity + 'Type' => 'text/csv', + 'Encoding' => '7bit', + 'Data' => [ map { "$_\n" } + $self->call_details('prepend_billed_number' => 1) + ], + 'Disposition' => 'attachment', + 'Filename' => 'usage-'. $self->invnum. '.csv', + ; + + } + + if ( $conf->exists('invoice_email_pdf') ) { + + #attaching pdf too: + # multipart/mixed + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + # application/pdf + + my $related = build MIME::Entity 'Type' => 'multipart/related', + 'Encoding' => '7bit'; + + #false laziness w/Misc::send_email + $related->head->replace('Content-type', + $related->mime_type. + '; boundary="'. $related->head->multipart_boundary. '"'. + '; type=multipart/alternative' + ); + + $related->add_part($alternative); + + $related->add_part($image); + + my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt); + + $return{'mimeparts'} = [ $related, $pdf, @otherparts ]; + + } else { + + #no other attachment: + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + + $return{'content-type'} = 'multipart/related'; + $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; + $return{'type'} = 'multipart/alternative'; #Content-Type of first part... + #$return{'disposition'} = 'inline'; + + } + + } else { + + if ( $conf->exists('invoice_email_pdf') ) { + warn "$me creating PDF attachment" + if $DEBUG; + + #mime parts arguments a la MIME::Entity->build(). + $return{'mimeparts'} = [ + { $self->mimebuild_pdf(\%opt) } + ]; + } + + 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 { + + 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(\%opt) ]; + } + + } + + } + + %return; + +} + +=item mimebuild_pdf + +Returns a list suitable for passing to MIME::Entity->build(), representing +this invoice as PDF attachment. + +=cut + +sub mimebuild_pdf { + my $self = shift; + ( + 'Type' => 'application/pdf', + 'Encoding' => 'base64', + 'Data' => [ $self->print_pdf(@_) ], + 'Disposition' => 'attachment', + 'Filename' => 'invoice-'. $self->invnum. '.pdf', + ); +} + +=item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ] + +Sends this invoice to the destinations configured for this customer: sends +email, prints and/or faxes. See L. + +Options can be passed as a hashref (recommended) or as a list of up to +four values for templatename, agentnum, invoice_from and amount. + +I