X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=eefcc80bc37667e46d2cdc0cfe2b43a7a7257400;hb=947c1f964f1304242f8a6ffabacccf040f1d505e;hp=4e28af39c9006fa36c6ba4d97e6f9d602ed4ab07;hpb=50f5d60aef5ee82be33c978db6424372bfd7995b;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 4e28af39c..eefcc80bc 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -16,6 +16,7 @@ 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_credit; @@ -82,6 +83,8 @@ 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 =item invnum - primary key (assigned automatically for new invoices) @@ -93,10 +96,26 @@ L and L for conversion functions. =item charged - amount of this invoice +=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 agent_invid - legacy invoice number + =back =head1 METHODS @@ -141,7 +160,50 @@ Really, don't use it. sub delete { my $self = shift; return "Can't delete closed invoice" if $self->closed =~ /^Y/i; - $self->SUPER::delete(@_); + + 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; + + 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 + )) { + + foreach my $linked ( $self->$table() ) { + my $error = $linked->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + } + + my $error = $self->SUPER::delete(@_); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + ''; + } =item replace OLD_RECORD @@ -183,17 +245,16 @@ 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_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 ''; @@ -201,6 +262,22 @@ sub check { $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 Returns a list consisting of the total previous balance for this customer, @@ -235,6 +312,25 @@ sub cust_bill_pkg { ); } +=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 @@ -407,6 +503,16 @@ sub cust_pay { #; } +sub cust_pay_batch { + my $self = shift; + qsearch('cust_pay_batch', { 'invnum' => $self->invnum } ); +} + +sub cust_bill_pay_batch { + my $self = shift; + qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } ); +} + =item cust_bill_pay Returns all payment applications (see L) for this invoice. @@ -421,6 +527,8 @@ sub cust_bill_pay { =item cust_credited +=item cust_credit_bill + Returns all applied credits (see L) for this invoice. =cut @@ -432,6 +540,42 @@ sub cust_credited { ; } +sub cust_credit_bill { + shift->cust_credited(@_); +} + +=item cust_bill_pay_pkgnum PKGNUM + +Returns all payment applications (see L) for this invoice +with matching pkgnum. + +=cut + +sub cust_bill_pay_pkgnum { + my( $self, $pkgnum ) = @_; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + } + ); +} + +=item cust_credited_pkgnum PKGNUM + +Returns all applied credits (see L) for this invoice +with matching pkgnum. + +=cut + +sub cust_credited_pkgnum { + my( $self, $pkgnum ) = @_; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + } + ); +} + =item tax Returns the tax amount (see L) for this invoice. @@ -465,12 +609,35 @@ sub owed { $balance; } -=item apply_payments_and_credits +sub owed_pkgnum { + my( $self, $pkgnum ) = @_; + + #my $balance = $self->charged; + my $balance = 0; + $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); + + $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); + $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); + + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} + +=item apply_payments_and_credits [ OPTION => VALUE ... ] + +Applies unapplied payments and credits to this invoice. + +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. + +If there is an error, returns the error, otherwise returns false. =cut sub apply_payments_and_credits { - my $self = shift; + my( $self, %options ) = @_; local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -488,6 +655,13 @@ sub apply_payments_and_credits { 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 = ''; @@ -525,31 +699,42 @@ sub apply_payments_and_credits { die "guru meditation #12 and 35"; } + my $unapp_amount; 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 ) ), - }; + $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; - - $app = new FS::cust_credit_bill { - 'crednum' => $credit->crednum, - 'amount' => sprintf('%.2f', min( $credit->credited, $self->owed ) ), - }; + $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; + my $error = $app->insert(%options); if ( $error ) { $dbh->rollback if $oldAutoCommit; return "Error inserting ". $app->table. " record: $error"; @@ -710,8 +895,11 @@ sub generate_email { push @otherparts, build MIME::Entity 'Type' => 'text/csv', 'Encoding' => '7bit', - 'Data' => [ map { "$_\n" } $self->call_details ], + 'Data' => [ map { "$_\n" } + $self->call_details('prepend_billed_number' => 1) + ], 'Disposition' => 'attachment', + 'Filename' => 'usage-'. $self->invnum. '.csv', ; } @@ -1728,7 +1916,7 @@ 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" + warn "$me print_generic called on $self with suffix $params{template}\n" if $DEBUG; my $format = $params{format}; @@ -1780,6 +1968,7 @@ sub print_generic { 'smallfooter' => sub { map "$_", @_ }, 'returnaddress' => sub { map "$_", @_ }, 'coupon' => sub { map "$_", @_ }, + 'summary' => sub { map "$_", @_ }, }, 'html' => { 'notes' => @@ -1813,6 +2002,7 @@ sub print_generic { } @_ }, 'coupon' => sub { "" }, + 'summary' => sub { "" }, }, 'template' => { 'notes' => @@ -1843,6 +2033,7 @@ sub print_generic { } @_ }, 'coupon' => sub { "" }, + 'summary' => sub { "" }, }, ); @@ -1959,6 +2150,14 @@ sub print_generic { 'unitprices' => $conf->exists('invoice-unitprice'), ); + $invoice_data{finance_section} = ''; + if ( $conf->config('finance_pkgclass') ) { + my $pkg_class = + qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') }); + $invoice_data{finance_section} = $pkg_class->categoryname; + } + $invoice_data{finance_amount} = '0.00'; + my $countrydefault = $conf->config('countrydefault') || 'US'; my $prefix = $cust_main->has_ship_address ? 'ship_' : ''; foreach ( qw( contact company address1 address2 city state zip country fax) ){ @@ -2005,11 +2204,19 @@ sub print_generic { # 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{'true_previous_balance'} = sprintf("%.2f", $self->previous_balance); + $invoice_data{'balance_adjustments'} = sprintf("%.2f", $self->previous_balance - $self->billing_balance); $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); $invoice_data{'balance'} = sprintf("%.2f", $balance_due); my $agentnum = $self->cust_main->agentnum; + my $summarypage = ''; + if ( $conf->exists('invoice_usesummary', $agentnum) ) { + $summarypage = 1; + } + $invoice_data{'summarypage'} = $summarypage; + #do variable substitution in notes, footer, smallfooter foreach my $include (qw( notes footer smallfooter coupon )) { @@ -2069,6 +2276,7 @@ sub print_generic { 'template' => '', ); my $other_money_char = $other_money_chars{$format}; + $invoice_data{'dollar'} = $other_money_char; my @detail_items = (); my @total_items = (); @@ -2079,25 +2287,31 @@ sub print_generic { $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), + 'summarized' => $summarypage ? 'Y' : '', }; my $taxtotal = 0; my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees', - 'subtotal' => $taxtotal }; # adjusted below + 'subtotal' => $taxtotal, # adjusted below + 'summarized' => $summarypage ? 'Y' : '', + }; my $adjusttotal = 0; my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments', - 'subtotal' => 0 }; # adjusted below + 'subtotal' => 0, # adjusted below + 'summarized' => $summarypage ? 'Y' : '', + }; my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y'; my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum); my $late_sections = []; if ( $multisection ) { - push @sections, $self->_items_sections( $late_sections ); + push @sections, + $self->_items_sections( $late_sections, $summarypage, $escape_function ); }else{ push @sections, { 'description' => '', 'subtotal' => '' }; } @@ -2142,6 +2356,10 @@ sub print_generic { foreach my $section (@sections, @$late_sections) { + $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} ) + if ( $invoice_data{finance_section} && + $section->{'description'} eq $invoice_data{finance_section} ); + $section->{'subtotal'} = $other_money_char. sprintf('%.2f', $section->{'subtotal'}) if $multisection; @@ -2158,6 +2376,7 @@ sub print_generic { $options{'escape_function'} = $escape_function; $options{'format_function'} = sub { () } unless $unsquelched; $options{'unsquelched'} = $unsquelched; + $options{'summary_page'} = $summarypage; foreach my $line_item ( $self->_items_pkg(%options) ) { my $detail = { @@ -2196,6 +2415,9 @@ sub print_generic { } + $invoice_data{current_less_finance} = + sprintf('%.2f', $self->charged - $invoice_data{finance_amount} ); + if ( $multisection && !$conf->exists('disable_previous_balance') ) { unshift @sections, $previous_section if $pr_total; } @@ -2252,7 +2474,7 @@ sub print_generic { } } $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal); - + push @buf,['','-----------']; push @buf,[( $conf->exists('disable_previous_balance') ? 'Total Charges' @@ -2327,7 +2549,7 @@ sub print_generic { foreach my $credit ( $self->_items_credits('trim_len'=>32) ) { push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ]; } - + # payments my $paymenttotal = 0; foreach my $payment ( $self->_items_payments ) { @@ -2367,7 +2589,11 @@ sub print_generic { $total->{'total_item'} = &$embolden_function($self->balance_due_msg); $total->{'total_amount'} = &$embolden_function( - $other_money_char. sprintf('%.2f', $self->owed + $pr_total ) + $other_money_char. sprintf('%.2f', $summarypage + ? $self->charged + + $self->billing_balance + : $self->owed + $pr_total + ) ); if ( $multisection ) { $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '. @@ -2386,6 +2612,49 @@ sub print_generic { if $unsquelched; } + my @includelist = (); + push @includelist, 'summary' if $summarypage; + foreach my $include ( @includelist ) { + + my $inc_file = $conf->key_orbase("invoice_${format}$include", $template); + my @inc_src; + + if ( length( $conf->config($inc_file, $agentnum) ) ) { + + @inc_src = $conf->config($inc_file, $agentnum); + + } 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, $agentnum) ); + + } + + 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_lines = 0; my $wasfunc = 0; foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy @@ -2662,21 +2931,30 @@ sub _date_pretty { sub _items_sections { my $self = shift; my $late = shift; + my $summarypage = shift; + my $escape = shift; my %s = (); my %l = (); + my %not_tax = (); foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { - if ( $cust_bill_pkg->pkgnum > 0 ) { + my $usage = $cust_bill_pkg->usage; foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) { + next if ( $display->summary && $summarypage ); + my $desc = $display->section; my $type = $display->type; - if ( $display->post_total ) { + if ( $cust_bill_pkg->pkgnum > 0 ) { + $not_tax{$desc} = 1; + } + + if ( $display->post_total && !$summarypage ) { if (! $type || $type eq 'S') { $l{$desc} += $cust_bill_pkg->setup if ( $cust_bill_pkg->setup != 0 ); @@ -2720,16 +2998,29 @@ sub _items_sections { } - } - } - push @$late, map { { 'description' => $_, + my %cache = map { $_->categoryname => $_ } + qsearch( 'pkg_category', {disabled => 'Y'} ); + $cache{$_->categoryname} = $_ + foreach qsearch( 'pkg_category', {disabled => ''} ); + + push @$late, map { { 'description' => &{$escape}($_), 'subtotal' => $l{$_}, 'post_total' => 1, - } } sort keys %l; - - map { {'description' => $_, 'subtotal' => $s{$_}} } sort keys %s; + } } + sort { $cache{$a}->weight <=> $cache{$b}->weight } keys %l; + + map { { 'description' => &{$escape}($_), + 'subtotal' => $s{$_}, + 'summarized' => $not_tax{$_} ? '' : 'Y', + 'tax_section' => $not_tax{$_} ? '' : 'Y', + } } + sort { $cache{$a}->weight <=> $cache{$b}->weight } + ( $summarypage + ? ( grep { exists($s{$_}) || !$cache{$_}->disabled } keys %cache ) + : ( keys %s ) + ); } @@ -2811,6 +3102,7 @@ sub _items_cust_bill_pkg { my $format_function = $opt{format_function} || ''; my $unsquelched = $opt{unsquelched} || ''; my $section = $opt{section}->{description} if $opt{section}; + my $summary_page = $opt{summary_page} || ''; my @b = (); my ($s, $r, $u) = ( undef, undef, undef ); @@ -2830,14 +3122,13 @@ sub _items_cust_bill_pkg { ? $_->section eq $section : 1 } + grep { $_->summary || !$summary_page } $cust_bill_pkg->cust_bill_pkg_display ) { my $type = $display->type; - my $cust_pkg = $cust_bill_pkg->cust_pkg; - my $desc = $cust_bill_pkg->desc; $desc = substr($desc, 0, 50). '...' if $format eq 'latex' && length($desc) > 50; @@ -2849,6 +3140,8 @@ sub _items_cust_bill_pkg { if ( $cust_bill_pkg->pkgnum > 0 ) { + my $cust_pkg = $cust_bill_pkg->cust_pkg; + if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) { my $description = $desc; @@ -3045,20 +3338,36 @@ sub _items_payments { } -=item call_details +=item call_details [ OPTION => VALUE ... ] Returns an array of CSV strings representing the call details for this invoice +The only option available is the boolean prepend_billed_number =cut sub call_details { - my $self = shift; - map { $_->details( 'format_function' => sub{ shift }, - 'escape_function' => sub{ return() }, - ) - } - grep { $_->pkgnum } - $self->cust_bill_pkg; + my ($self, %opt) = @_; + + my $format_function = sub { shift }; + + if ($opt{prepend_billed_number}) { + $format_function = sub { + my $detail = shift; + my $row = shift; + + $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail; + + }; + } + + my @details = map { $_->details( 'format_function' => $format_function, + 'escape_function' => sub{ return() }, + ) + } + grep { $_->pkgnum } + $self->cust_bill_pkg; + my $header = $details[0]; + ( $header, grep { $_ ne $header } @details ); }