From d647477e5f8fa8b988b42379847f5367d440f936 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Wed, 25 Sep 2013 17:11:18 -0700 Subject: optionally display payments/credits on invoice based on date received, #24850 --- FS/FS/Conf.pm | 7 +++++ FS/FS/Template_Mixin.pm | 17 ++---------- FS/FS/cust_bill.pm | 74 ++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 03280c484..bb43b453d 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -4162,6 +4162,13 @@ and customer address. Include units.', 'type' => 'checkbox', }, + { + 'key' => 'previous_balance-payments_since', + 'section' => 'invoicing', + 'description' => 'Instead of showing payments (and credits) applied to the invoice, show those received since the previous invoice date.', + 'type' => 'checkbox', + }, + { 'key' => 'balance_due_below_line', 'section' => 'invoicing', diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index db3885443..f55fc664c 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -608,23 +608,12 @@ sub print_generic { # summary formats $invoice_data{'last_bill'} = {}; - # returns the last unpaid bill, not the last bill - #my $last_bill = $pr_cust_bill[-1]; - if ( $self->custnum && $self->invnum ) { - # THIS returns the customer's last bill before this one - my $last_bill = qsearchs({ - 'table' => 'cust_bill', - 'hashref' => { 'custnum' => $self->custnum, - 'invnum' => { op => '<', value => $self->invnum }, - }, - 'order_by' => ' ORDER BY invnum DESC LIMIT 1' - }); - if ( $last_bill ) { + if ( $self->previous_bill ) { + my $last_bill = $self->previous_bill; $invoice_data{'last_bill'} = { '_date' => $last_bill->_date, #unformatted - # all we need for now }; my (@payments, @credits); # for formats that itemize previous payments @@ -1167,7 +1156,7 @@ sub print_generic { $adjust_section->{'pretotal'} = $self->mt('New charges total').' '. $other_money_char. sprintf('%.2f', $self->charged ); } - }else{ + } else { push @total_items, $total; } push @buf,['','-----------']; diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index fc6a7ddbe..97dd38be5 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -422,6 +422,25 @@ sub display_invnum { } } +=item previous_bill + +Returns the customer's last invoice before this one. + +=cut + +sub previous_bill { + my $self = shift; + if ( !$self->get('previous_bill') ) { + $self->set('previous_bill', qsearchs({ + 'table' => 'cust_bill', + 'hashref' => { 'custnum' => $self->custnum, + '_date' => { op=>'<', value=>$self->_date } }, + 'order_by' => 'ORDER BY _date DESC LIMIT 1', + }) ); + } + $self->get('previous_bill'); +} + =item previous Returns a list consisting of the total previous balance for this customer, @@ -3109,12 +3128,25 @@ sub _items_credits { my @b; #credits - foreach ( $self->cust_credited ) { + my @objects; + if ( $self->conf->exists('previous_balance-payments_since') ) { + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_credit', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $date}, + }); + # hard to do this in the qsearch... + @objects = grep { $_->_date < $self->_date } @objects; + } else { + @objects = $self->cust_credited; + } - #something more elaborate if $_->amount ne $_->cust_credit->credited ? + foreach my $obj ( @objects ) { + my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit; - my $reason = substr($_->cust_credit->reason, 0, $trim_len); - $reason .= '...' if length($reason) < length($_->cust_credit->reason); + my $reason = substr($cust_credit->reason, 0, $trim_len); + $reason .= '...' if length($reason) < length($cust_credit->reason); $reason = " ($reason) " if $reason; push @b, { @@ -3122,8 +3154,8 @@ sub _items_credits { # " (". time2str("%x",$_->cust_credit->_date) .")". # $reason, 'description' => $self->mt('Credit applied').' '. - time2str($date_format,$_->cust_credit->_date). $reason, - 'amount' => sprintf("%.2f",$_->amount), + time2str($date_format,$obj->_date). $reason, + 'amount' => sprintf("%.2f",$obj->amount), }; } @@ -3135,21 +3167,31 @@ 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 ? + my $detailed = $self->conf->exists('invoice_payment_details'); + my @objects; + if ( $self->conf->exists('previous_balance-payments_since') ) { + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_pay', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $date}, + }); + @objects = grep { $_->_date < $self->_date } @objects; + } else { + @objects = $self->cust_bill_pay; + } + foreach my $obj (@objects) { + my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay; my $desc = $self->mt('Payment received').' '. - time2str($date_format,$_->cust_pay->_date ); - $desc .= $self->mt(' via ' . $_->cust_pay->payby_payinfo_pretty) - if ( $self->conf->exists('invoice_payment_details') ); - + time2str($date_format, $cust_pay->_date ); + $desc .= $self->mt(' via ' . $cust_pay->payby_payinfo_pretty) + if $detailed; + push @b, { 'description' => $desc, - 'amount' => sprintf("%.2f", $_->amount ) + 'amount' => sprintf("%.2f", $obj->amount ) }; - } @b; -- cgit v1.2.1 From 172c6d7e47a3a9ceb8ea04bd2750556a3afee24a Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Wed, 25 Sep 2013 17:11:24 -0700 Subject: allow for taxes when using "fee" event to negate credit balance, #24991 --- FS/FS/part_event/Action/fee.pm | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/FS/FS/part_event/Action/fee.pm b/FS/FS/part_event/Action/fee.pm index cd9e200c8..c2b4673fa 100644 --- a/FS/FS/part_event/Action/fee.pm +++ b/FS/FS/part_event/Action/fee.pm @@ -32,7 +32,48 @@ sub _calc_fee { if ( $balance >= 0 ) { return 0; } elsif ( (-1 * $balance) < $self->option('charge') ) { - return -1 * $balance; + my $total = -1 * $balance; + # if it's tax exempt, then we're done + # XXX we also bail out if you're using external tax tables, because + # they're definitely NOT linear and we haven't yet had a reason to + # make that case work. + return $total if $self->option('setuptax') eq 'Y' + or FS::Conf->new->exists('enable_taxproducts'); + + # estimate tax rate + # false laziness with xmlhttp-calculate_taxes, cust_main::Billing, etc. + # XXX not accurate with monthly exemptions + my $cust_main = $cust_object->cust_main; + my $taxlisthash = {}; + my $charge = FS::cust_bill_pkg->new({ + setup => $total, + recur => 0, + details => [] + }); + my $part_pkg = FS::part_pkg->new({ + taxclass => $self->option('taxclass') + }); + my $error = $cust_main->_handle_taxes( + FS::part_pkg->new({ taxclass => ($self->option('taxclass') || '') }), + $taxlisthash, + $charge, + FS::cust_pkg->new({custnum => $cust_main->custnum}), + ); + if ( $error ) { + warn "error estimating taxes for breakage charge: custnum ".$cust_main->custnum."\n"; + return $total; + } + # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ] + my $total_rate = 0; + my @taxes = map { $_->[0] } values %$taxlisthash; + foreach (@taxes) { + $total_rate += $_->tax; + } + return $total if $total_rate == 0; # no taxes apply + + my $total_cents = $total * 100; + my $charge_cents = sprintf('%.0f', $total_cents * 100/(100 + $total_rate)); + return ($charge_cents / 100); } } -- cgit v1.2.1 From c0cd3e464e443a2d42d7f519ba01685087c6b423 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Wed, 25 Sep 2013 17:11:30 -0700 Subject: minor convenience for invoice templates, #24850 --- FS/FS/Template_Mixin.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index f55fc664c..356de5bed 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -755,6 +755,7 @@ sub print_generic { my $taxtotal = 0; my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'), 'subtotal' => $taxtotal, # adjusted below + 'tax_section' => 1, }; my $tax_weight = _pkg_category($tax_section->{description}) ? _pkg_category($tax_section->{description})->weight -- cgit v1.2.1 From 0792a884aea4a30b1c227875a88270928602ff00 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 27 Sep 2013 13:08:05 -0700 Subject: prevent nonexistent customer links from breaking ticket display, #25063 --- rt/lib/RT/URI/freeside/Internal.pm | 36 +++++++++++++++++++++++++-------- rt/share/html/Ticket/Elements/Customers | 10 +++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/rt/lib/RT/URI/freeside/Internal.pm b/rt/lib/RT/URI/freeside/Internal.pm index b0962860d..5dc92d421 100644 --- a/rt/lib/RT/URI/freeside/Internal.pm +++ b/rt/lib/RT/URI/freeside/Internal.pm @@ -152,6 +152,9 @@ sub AsStringLong { if ( $table eq 'cust_main' ) { my $rec = $self->_FreesideGetRecord(); + if (!$rec) { + return 'Customer #'.$self->{'fspkey'}.' (not found)'; + } return '' . small_custview( $rec->{'_object'}, scalar(FS::Conf->new->config('countrydefault')), @@ -192,21 +195,38 @@ sub CustomerResolver { } elsif ( $self->{fstable} eq 'cust_svc' ) { my $rec = $self->_FreesideGetRecord(); - return if !$rec; - my $cust_pkg = $rec->{'_object'}->cust_pkg; - if ( $cust_pkg ) { - my $URI = RT::URI->new($self->CurrentUser); - $URI->FromURI('freeside://freeside/cust_main/'.$cust_pkg->custnum); - return $URI->Resolver; + if ($rec) { + my $cust_pkg = $rec->{'_object'}->cust_pkg; + if ( $cust_pkg ) { + my $URI = RT::URI->new($self->CurrentUser); + $URI->FromURI('freeside://freeside/cust_main/'.$cust_pkg->custnum); + return $URI->Resolver; + } } + return; } return; } sub CustomerInfo { my $self = shift; - $self = $self->CustomerResolver or return; - my $rec = $self->_FreesideGetRecord() or return; + $self = $self->CustomerResolver; + my $rec; + my $rec = $self->_FreesideGetRecord() if $self; + if (!$rec) { + # AsStringLong will report an error; + # here, just avoid breaking things + my $error = { + AgentName => '', + CustomerClass => '', + CustomerTags => [], + Referral => '', + InvoiceEmail => '', + BillingType => '', + }; + return $error; + } + my $cust_main = delete $rec->{_object}; my $agent = $cust_main->agent; my $class = $cust_main->cust_class; diff --git a/rt/share/html/Ticket/Elements/Customers b/rt/share/html/Ticket/Elements/Customers index d90ef1c44..fed678380 100644 --- a/rt/share/html/Ticket/Elements/Customers +++ b/rt/share/html/Ticket/Elements/Customers @@ -43,10 +43,12 @@ while (my $link = $customers->Next) { } elsif ( $uri =~ /cust_svc\/(\d+)/ ) { my $svc = $link->TargetURI->Resolver; my $cust = $svc->CustomerResolver; - my $custnum = $cust->{fspkey}; - $cust_main{$custnum} ||= $cust; - $cust_svc{$custnum} ||= []; - push @{$cust_svc{$custnum}}, $svc; + if ( $cust ) { + my $custnum = $cust->{fspkey}; + $cust_main{$custnum} ||= $cust if $cust; + $cust_svc{$custnum} ||= []; + push @{$cust_svc{$custnum}}, $svc if $svc; + } } } @custnums = sort { $a <=> $b } keys %cust_main; -- cgit v1.2.1 From e3012c0751dad6710ea35b6d074b551bffdad09b Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 27 Sep 2013 16:02:03 -0700 Subject: clean up invalid ticket links on upgrade, #25067 --- FS/FS/TicketSystem.pm | 15 +++++++++++++++ rt/lib/RT/URI/freeside/Internal.pm | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/FS/FS/TicketSystem.pm b/FS/FS/TicketSystem.pm index c1c69fa3f..fa54e0bbd 100644 --- a/FS/FS/TicketSystem.pm +++ b/FS/FS/TicketSystem.pm @@ -342,6 +342,21 @@ sub _upgrade_data { or die $dbh->errstr; $cve_2013_3373_sth->execute or die $cve_2013_3373_sth->errstr; + # Remove dangling customer links, if any + my %target_pkey = ('cust_main' => 'custnum', 'cust_svc' => 'svcnum'); + for my $table (keys %target_pkey) { + my $pkey = $target_pkey{$table}; + my $rows = $dbh->do( + "DELETE FROM links WHERE id IN(". + "SELECT links.id FROM links LEFT JOIN $table ON (links.target = ". + "'freeside://freeside/$table/' || $table.$pkey) ". + "WHERE links.target like 'freeside://freeside/$table/%' ". + "AND $table.$pkey IS NULL". + ")" + ) or die $dbh->errstr; + warn "Removed $rows dangling ticket-$table links\n" if $rows > 0; + } + return; } diff --git a/rt/lib/RT/URI/freeside/Internal.pm b/rt/lib/RT/URI/freeside/Internal.pm index 5dc92d421..d1479b5f9 100644 --- a/rt/lib/RT/URI/freeside/Internal.pm +++ b/rt/lib/RT/URI/freeside/Internal.pm @@ -211,7 +211,6 @@ sub CustomerResolver { sub CustomerInfo { my $self = shift; $self = $self->CustomerResolver; - my $rec; my $rec = $self->_FreesideGetRecord() if $self; if (!$rec) { # AsStringLong will report an error; -- cgit v1.2.1 From eb3bd392a89b8b666dc512951e78913c05b98810 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 27 Sep 2013 17:19:36 -0700 Subject: invoice configurations, #24723 --- FS/FS/Mason.pm | 2 + FS/FS/Schema.pm | 45 ++++ FS/FS/Template_Mixin.pm | 117 +++++--- FS/FS/agent.pm | 17 ++ FS/FS/cust_bill.pm | 274 ++++++------------- FS/FS/cust_main.pm | 4 +- FS/FS/invoice_conf.pm | 274 +++++++++++++++++++ FS/FS/invoice_mode.pm | 157 +++++++++++ FS/FS/part_event/Action/cust_bill_email.pm | 10 +- FS/FS/part_event/Action/cust_bill_print.pm | 9 + FS/FS/part_event/Action/cust_bill_print_pdf.pm | 9 + FS/FS/part_event/Action/cust_bill_send.pm | 9 + FS/FS/part_event/Action/cust_bill_send_agent.pm | 17 +- .../part_event/Action/cust_bill_send_alternate.pm | 5 +- .../part_event/Action/cust_bill_send_if_newest.pm | 18 +- FS/FS/part_event/Action/cust_bill_send_reminder.pm | 12 +- FS/FS/part_event/Action/cust_statement_send.pm | 3 +- FS/MANIFEST | 4 + FS/t/invoice_conf.t | 5 + FS/t/invoice_mode.t | 5 + bin/generate-table-module | 4 +- httemplate/browse/invoice_conf.html | 70 +++++ httemplate/edit/elements/edit.html | 1 + httemplate/edit/invoice_conf.html | 296 +++++++++++++++++++++ httemplate/edit/process/invoice_conf.html | 21 ++ httemplate/elements/columnstart.html | 77 +++++- httemplate/elements/menu.html | 1 + httemplate/elements/tr-select-invoice_mode.html | 10 + httemplate/misc/delete-invoice_conf.html | 19 ++ httemplate/misc/email-invoice.cgi | 2 +- httemplate/misc/fax-invoice.cgi | 2 +- httemplate/misc/print-invoice.cgi | 2 +- httemplate/misc/send-invoice.cgi | 5 + httemplate/view/cust_bill.cgi | 35 ++- httemplate/view/cust_statement.html | 4 +- httemplate/view/elements/cust_bill-typeset | 7 +- 36 files changed, 1284 insertions(+), 268 deletions(-) create mode 100644 FS/FS/invoice_conf.pm create mode 100644 FS/FS/invoice_mode.pm create mode 100644 FS/t/invoice_conf.t create mode 100644 FS/t/invoice_mode.t create mode 100644 httemplate/browse/invoice_conf.html create mode 100644 httemplate/edit/invoice_conf.html create mode 100644 httemplate/edit/process/invoice_conf.html create mode 100644 httemplate/elements/tr-select-invoice_mode.html create mode 100644 httemplate/misc/delete-invoice_conf.html diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 780e3ffaf..f1fc5cba4 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -353,6 +353,8 @@ if ( -e $addl_handler_use_file ) { use FS::sales_pkg_class; use FS::svc_alarm; use FS::cable_model; + use FS::invoice_mode; + use FS::invoice_conf; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index ed3790452..16546b32d 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -4377,6 +4377,51 @@ sub tables_hashref { 'index' => [ [ 'derivenum', ], ], }, + 'invoice_mode' => { + 'columns' => [ + 'modenum', 'serial', '', '', '', '', + 'agentnum', 'int', 'NULL', '', '', '', + 'modename', 'varchar', '', 32, '', '', + ], + 'primary_key' => 'modenum', + 'unique' => [ ], + 'index' => [ ], + }, + + 'invoice_conf' => { + 'columns' => [ + 'confnum', 'serial', '', '', '', '', + 'modenum', 'int', '', '', '', '', + 'locale', 'varchar', 'NULL', 16, '', '', + 'notice_name', 'varchar', 'NULL', 64, '', '', + 'subject', 'varchar', 'NULL', 64, '', '', + 'htmlnotes', 'text', 'NULL', '', '', '', + 'htmlfooter', 'text', 'NULL', '', '', '', + 'htmlsummary', 'text', 'NULL', '', '', '', + 'htmlreturnaddress', 'text', 'NULL', '', '', '', + 'latexnotes', 'text', 'NULL', '', '', '', + 'latexfooter', 'text', 'NULL', '', '', '', + 'latexsummary', 'text', 'NULL', '', '', '', + 'latexcoupon', 'text', 'NULL', '', '', '', + 'latexsmallfooter', 'text', 'NULL', '', '', '', + 'latexreturnaddress', 'text', 'NULL', '', '', '', + 'latextopmargin', 'varchar', 'NULL', 16, '', '', + 'latexheadsep', 'varchar', 'NULL', 16, '', '', + 'latexaddresssep', 'varchar', 'NULL', 16, '', '', + 'latextextheight', 'varchar', 'NULL', 16, '', '', + 'latexextracouponspace','varchar', 'NULL', 16, '', '', + 'latexcouponfootsep', 'varchar', 'NULL', 16, '', '', + 'latexcouponamountenclosedsep', 'varchar', 'NULL', 16, '', '', + 'latexcoupontoaddresssep', 'varchar', 'NULL', 16, '', '', + 'latexverticalreturnaddress', 'char', 'NULL', 1, '', '', + 'latexcouponaddcompanytoaddress', 'char', 'NULL', 1, '', '', + 'logo_png', 'blob', 'NULL', '', '', '', + 'logo_eps', 'blob', 'NULL', '', '', '', + 'lpr', 'varchar', 'NULL', $char_d, '', '', + ], + 'primary_key' => 'confnum', + 'unique' => [ [ 'modenum', 'locale' ] ], + }, # name type nullability length default local diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 356de5bed..840df7558 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -18,6 +18,7 @@ use FS::Record qw( qsearch qsearchs ); use FS::Misc qw( generate_ps generate_pdf ); use FS::pkg_category; use FS::pkg_class; +use FS::invoice_mode; use FS::L10N; $DEBUG = 0; @@ -30,12 +31,51 @@ FS::UID->install_callback( sub { $date_format_long = $conf->config('date_format_long') || '%b %o, %Y'; } ); -=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ] +=item conf [ MODE ] + +Returns a configuration handle (L) set to the customer's locale. + +If the "mode" pseudo-field is set on the object, the configuration handle +will be an L for that invoice mode (and the customer's +locale). + +=cut + +sub conf { + my $self = shift; + my $mode = $self->get('mode'); + if ($self->{_conf} and !defined($mode)) { + return $self->{_conf}; + } + + my $cust_main = $self->cust_main; + my $locale = $cust_main ? $cust_main->locale : ''; + my $conf; + if ( $mode ) { + if ( ref $mode and $mode->isa('FS::invoice_mode') ) { + $mode = $mode->modenum; + } elsif ( $mode =~ /\D/ ) { + die "invalid invoice mode $mode"; + } + $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale }); + if (!$conf) { + $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' }); + # it doesn't have a locale, but system conf still might + $conf->set('locale' => $locale) if $conf; + } + } + # if $mode is unspecified, or if there is no invoice_conf matching this mode + # and locale, then use the system config only (but with the locale) + $conf ||= FS::Conf->new({ 'locale' => $locale }); + # cache it + return $self->{_conf} = $conf; +} + +=item print_text OPTIONS Returns an text invoice, as a list of lines. -Options can be passed as a hashref (recommended) or as a list of time, template -and then any key/value pairs for any other options. +Options can be passed as a hash. I