From: Ivan Kohler Date: Wed, 26 Feb 2014 00:23:14 +0000 (-0800) Subject: Merge branch 'master' of git.freeside.biz:/home/git/freeside X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=439d00a59c67a7d9d53b5d89c14ab332be16e38b;hp=0e0b58d3384aeaf24634319ceeb10a51ef3c26eb Merge branch 'master' of git.freeside.biz:/home/git/freeside --- diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm index 9de9eac06..4b165eb3f 100644 --- a/FS/FS/AccessRight.pm +++ b/FS/FS/AccessRight.pm @@ -356,6 +356,9 @@ tie my %rights, 'Tie::IxHash', 'Bulk edit package definitions', + 'Edit fee definitions', + { rightname=>'Edit global fee definitions', global=>1 }, + 'Edit billing events', { rightname=>'Edit global billing events', global=>1 }, diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 73d75562b..7bf5446ec 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -373,6 +373,10 @@ if ( -e $addl_handler_use_file ) { use FS::pbx_device; use FS::extension_device; use FS::cust_main_credit_limit; + use FS::cust_event_fee; + use FS::part_fee; + use FS::cust_bill_pkg_fee; + use FS::part_fee_msgcat; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Report/Table.pm b/FS/FS/Report/Table.pm index 7f593846e..17b12ae23 100644 --- a/FS/FS/Report/Table.pm +++ b/FS/FS/Report/Table.pm @@ -141,7 +141,7 @@ sub payments { sub credits { my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_; $self->scalar_sql(" - SELECT SUM(amount) + SELECT SUM(cust_credit.amount) FROM cust_credit LEFT JOIN cust_main USING ( custnum ) WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum). @@ -390,9 +390,6 @@ unspecified, defaults to all three. 'use_override': for line items generated by an add-on package, use the class of the add-on rather than the base package. -'freq': limit to packages with this frequency. Currently uses the part_pkg -frequency, so term discounted packages may give odd results. - 'distribute': for non-monthly recurring charges, ignore the invoice date. Instead, consider the line item's starting/ending dates. Determine the fraction of the line item duration that falls within the specified @@ -421,7 +418,8 @@ my $cust_bill_pkg_join = ' LEFT JOIN cust_main USING ( custnum ) LEFT JOIN cust_pkg USING ( pkgnum ) LEFT JOIN part_pkg USING ( pkgpart ) - LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart'; + LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart + LEFT JOIN part_fee USING ( feepart )'; sub cust_bill_pkg_setup { my $self = shift; @@ -434,7 +432,7 @@ sub cust_bill_pkg_setup { $agentnum ||= $opt{'agentnum'}; my @where = ( - 'pkgnum != 0', + '(pkgnum != 0 OR feepart IS NOT NULL)', $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_report_option(%opt), $self->in_time_period_and_agent($speriod, $eperiod, $agentnum), @@ -461,7 +459,7 @@ sub cust_bill_pkg_recur { my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg'; my @where = ( - 'pkgnum != 0', + '(pkgnum != 0 OR feepart IS NOT NULL)', $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_report_option(%opt), ); @@ -476,13 +474,14 @@ sub cust_bill_pkg_recur { $item_usage = 'usage'; #already calculated } else { - $item_usage = '( SELECT COALESCE(SUM(amount),0) + $item_usage = '( SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0) FROM cust_bill_pkg_detail WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )'; } my $recur_fraction = ''; if ( $opt{'distribute'} ) { + $where[0] = 'pkgnum != 0'; # specifically exclude fees push @where, "cust_main.agentnum = $agentnum" if $agentnum; push @where, "$cust_bill_pkg.sdate < $eperiod", @@ -521,7 +520,8 @@ Arguments as for C, plus: sub cust_bill_pkg_detail { my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_; - my @where = ( "cust_bill_pkg.pkgnum != 0" ); + my @where = + ( "(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart IS NOT NULL)" ); push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; @@ -536,7 +536,9 @@ sub cust_bill_pkg_detail { ; if ( $opt{'distribute'} ) { - # then limit according to the usage time, not the billing date + # exclude fees + $where[0] = 'cust_bill_pkg.pkgnum != 0'; + # and limit according to the usage time, not the billing date push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, 'cust_bill_pkg_detail.startdate' ); @@ -547,7 +549,7 @@ sub cust_bill_pkg_detail { ); } - my $total_sql = " SELECT SUM(amount) "; + my $total_sql = " SELECT SUM(cust_bill_pkg_detail.amount) "; $total_sql .= " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END " @@ -561,6 +563,7 @@ sub cust_bill_pkg_detail { LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum LEFT JOIN part_pkg USING ( pkgpart ) LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart + LEFT JOIN part_fee USING ( feepart ) WHERE ".join( ' AND ', grep $_, @where ); $self->scalar_sql($total_sql); @@ -683,14 +686,14 @@ sub with_classnum { @$classnum = grep /^\d+$/, @$classnum; my $in = 'IN ('. join(',', @$classnum). ')'; - if ( $use_override ) { - "( + my $expr = " ( COALESCE(part_pkg.classnum, 0) $in AND pkgpart_override IS NULL) - OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL ) - )"; - } else { - "COALESCE(part_pkg.classnum, 0) $in"; + OR ( COALESCE(part_fee.classnum, 0) $in AND feepart IS NOT NULL )"; + if ( $use_override ) { + $expr .= " + OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL )"; } + "( $expr )"; } sub with_usageclass { @@ -834,7 +837,8 @@ sub init_projection { # sdate/edate overlapping the ROI, for performance "INSERT INTO v_cust_bill_pkg ( SELECT cust_bill_pkg.*, - (SELECT COALESCE(SUM(amount),0) FROM cust_bill_pkg_detail + (SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0) + FROM cust_bill_pkg_detail WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum), cust_bill._date, cust_pkg.expire diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index bd5869821..bf516b28f 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -937,6 +937,29 @@ sub tables_hashref { ], }, + 'cust_event_fee' => { + 'columns' => [ + 'eventfeenum', 'serial', '', '', '', '', + 'eventnum', 'int', '', '', '', '', + 'billpkgnum', 'int', 'NULL', '', '', '', + 'feepart', 'int', '', '', '', '', + ], + 'primary_key' => 'eventfeenum', # I'd rather just use eventnum + 'unique' => [ [ 'billpkgnum' ], [ 'eventnum' ] ], # one-to-one link + 'index' => [ [ 'feepart' ] ], + 'foreign_keys' => [ + { columns => [ 'eventnum' ], + table => 'cust_event', + }, + { columns => [ 'billpkgnum' ], + table => 'cust_bill_pkg', + }, + { columns => [ 'feepart' ], + table => 'part_fee', + }, + ], + }, + 'cust_bill_pkg' => { 'columns' => [ 'billpkgnum', 'serial', '', '', '', '', @@ -959,6 +982,7 @@ sub tables_hashref { 'freq', 'varchar', 'NULL', $char_d, '', '', 'quantity', 'int', 'NULL', '', '', '', 'hidden', 'char', 'NULL', 1, '', '', + 'feepart', 'int', 'NULL', '', '', '', ], 'primary_key' => 'billpkgnum', 'unique' => [], @@ -975,6 +999,9 @@ sub tables_hashref { table => 'part_pkg', references => [ 'pkgpart' ], }, + { columns => [ 'feepart' ], + table => 'part_fee', + }, ], }, @@ -1017,7 +1044,7 @@ sub tables_hashref { 'cust_bill_pkg_display' => { 'columns' => [ - 'billpkgdisplaynum', 'serial', '', '', '', '', + 'billpkgdisplaynum', 'serial', '', '', '', '', 'billpkgnum', 'int', '', '', '', '', 'section', 'varchar', 'NULL', $char_d, '', '', #'unitsetup', @money_typen, '', '', #override the linked real one? @@ -1036,6 +1063,35 @@ sub tables_hashref { ], }, + 'cust_bill_pkg_fee' => { + 'columns' => [ + 'billpkgfeenum', 'serial', '', '', '', '', + 'billpkgnum', 'int', '', '', '', '', + 'base_invnum', 'int', '', '', '', '', + 'base_billpkgnum', 'int', 'NULL', '', '', '', + 'amount', @money_type, '', '', + ], + 'primary_key' => 'billpkgfeenum', + 'unique' => [], + 'index' => [ ['billpkgnum'], + ['base_invnum'], + ['base_billpkgnum'], + ], + 'foreign_keys' => [ + { columns => [ 'billpkgnum' ], + table => 'cust_bill_pkg', + }, + { columns => [ 'base_billpkgnum' ], + table => 'cust_bill_pkg', + references => [ 'billpkgnum' ], + }, + { columns => [ 'base_invnum' ], + table => 'cust_bill', + references => [ 'invnum' ], + }, + ], + }, + 'cust_bill_pkg_tax_location' => { 'columns' => [ 'billpkgtaxlocationnum', 'serial', '', '', '', '', @@ -1060,9 +1116,9 @@ sub tables_hashref { { columns => [ 'billpkgnum' ], table => 'cust_bill_pkg', }, - { columns => [ 'pkgnum' ], - table => 'cust_pkg', - }, + #{ columns => [ 'pkgnum' ], + # table => 'cust_pkg', + #}, # taxes can apply to fees { columns => [ 'locationnum' ], table => 'cust_location', }, @@ -3072,6 +3128,63 @@ sub tables_hashref { ], }, + 'part_fee' => { + 'columns' => [ + 'feepart', 'serial', '', '', '', '', + 'itemdesc', 'varchar', '', $char_d, '', '', + 'comment', 'varchar', 'NULL', 2*$char_d, '', '', + 'disabled', 'char', 'NULL', 1, '', '', + 'classnum', 'int', 'NULL', '', '', '', + 'taxclass', 'varchar', 'NULL', $char_d, '', '', + 'taxproductnum', 'int', 'NULL', '', '', '', + 'pay_weight', 'real', 'NULL', '', '', '', + 'credit_weight', 'real', 'NULL', '', '', '', + 'agentnum', 'int', 'NULL', '', '', '', + 'amount', @money_type, '', '', + 'percent', 'decimal', '', '7,4', '', '', + 'basis', 'varchar', '', 16, '', '', + 'minimum', @money_typen, '', '', + 'maximum', @money_typen, '', '', + 'limit_credit', 'char', 'NULL', 1, '', '', + 'setuprecur', 'char', '', 5, '', '', + 'taxable', 'char', 'NULL', 1, '', '', + ], + 'primary_key' => 'feepart', + 'unique' => [], + 'index' => [ [ 'disabled' ], [ 'classnum' ], [ 'agentnum' ] + ], + 'foreign_keys' => [ + { columns => [ 'classnum' ], + table => 'pkg_class', + }, + { columns => [ 'taxproductnum' ], + table => 'part_pkg_taxproduct', + }, + { columns => [ 'agentnum' ], + table => 'agent', + }, + ], + }, + + 'part_fee_msgcat' => { + 'columns' => [ + 'feepartmsgnum', 'serial', '', '', '', '', + 'feepart', 'int', '', '', '', '', + 'locale', 'varchar', '', 16, '', '', + 'itemdesc', 'varchar', '', $char_d, '', '', #longer/no limit? + 'comment', 'varchar', 'NULL', 2*$char_d, '', '', #longer/no limit? + ], + 'primary_key' => 'feepartmsgnum', + 'unique' => [ [ 'feepart', 'locale' ] ], + 'index' => [], + 'foreign_keys' => [ + { columns => [ 'feepart' ], + table => 'part_fee', + }, + ], + }, + + 'part_pkg_link' => { 'columns' => [ 'pkglinknum', 'serial', '', '', '', '', diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm index 569d98c2f..bf857a98a 100644 --- a/FS/FS/TemplateItem_Mixin.pm +++ b/FS/FS/TemplateItem_Mixin.pm @@ -62,7 +62,9 @@ sub desc { if ( $self->pkgnum > 0 ) { $self->itemdesc || $self->part_pkg->pkg_locale($locale); - } else { + } elsif ( $self->feepart ) { + $self->part_fee->itemdesc_locale($locale); + } else { # by the process of elimination it must be a tax my $desc = $self->itemdesc || 'Tax'; $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/; $desc; diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index c4c2d7fb0..131a23643 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -2452,6 +2452,8 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n" if $DEBUG > 1; + # quotation_pkgs are never fees, so don't worry about the case where + # part_pkg is undefined if ( $cust_bill_pkg->setup != 0 ) { my $description = $desc; @@ -2471,7 +2473,7 @@ sub _items_cust_bill_pkg { }; } - } elsif ( $cust_bill_pkg->pkgnum > 0 ) { + } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" if $DEBUG > 1; @@ -2739,29 +2741,21 @@ sub _items_cust_bill_pkg { } # recurring or usage with recurring charge - } else { #pkgnum tax or one-shot line item (??) + } else { # taxes and fees warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n" if $DEBUG > 1; - if ( $cust_bill_pkg->setup != 0 ) { - push @b, { - 'description' => $desc, - 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), - }; - } - if ( $cust_bill_pkg->recur != 0 ) { - push @b, { - 'description' => "$desc (". - $self->time2str_local('short', $cust_bill_pkg->sdate). ' - '. - $self->time2str_local('short', $cust_bill_pkg->edate). ')', - 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), - }; - } + # items of this kind should normally not have sdate/edate. + push @b, { + 'description' => $desc, + 'amount' => sprintf('%.2f', $cust_bill_pkg->setup + + $cust_bill_pkg->recur) + }; - } + } # if quotation / package line item / other line item - } + } # foreach $display $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount && $conf->exists('discount-show-always')); diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index b234d6f93..066ddf160 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -11,6 +11,7 @@ use FS::cust_pkg; use FS::cust_bill_pkg_detail; use FS::cust_bill_pkg_display; use FS::cust_bill_pkg_discount; +use FS::cust_bill_pkg_fee; use FS::cust_bill_pay_pkg; use FS::cust_credit_bill_pkg; use FS::cust_tax_exempt_pkg; @@ -46,8 +47,8 @@ FS::cust_bill_pkg - Object methods for cust_bill_pkg records =head1 DESCRIPTION An FS::cust_bill_pkg object represents an invoice line item. -FS::cust_bill_pkg inherits from FS::Record. The following fields are currently -supported: +FS::cust_bill_pkg inherits from FS::Record. The following fields are +currently supported: =over 4 @@ -220,8 +221,7 @@ sub insert { # XXX if we ever do tax-on-tax for these, this will have to change # since pkgnum will be zero $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum); - $link->set('locationnum', - $taxable_cust_bill_pkg->cust_pkg->tax_locationnum); + $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum); $link->set('taxable_cust_bill_pkg', ''); } @@ -256,6 +256,52 @@ sub insert { } } + my $fee_links = $self->get('cust_bill_pkg_fee'); + if ( $fee_links ) { + foreach my $link ( @$fee_links ) { + # very similar to cust_bill_pkg_tax_location, for obvious reasons + next if $link->billpkgfeenum; # don't try to double-insert + + my $target = $link->get('cust_bill_pkg'); # the line item of the fee + my $base = $link->get('base_cust_bill_pkg'); # line item it was based on + + if ( $target and $target->billpkgnum ) { + $link->set('billpkgnum', $target->billpkgnum); + # base_invnum => null indicates that the fee is based on its own + # invoice + $link->set('base_invnum', $target->invnum) unless $link->base_invnum; + $link->set('cust_bill_pkg', ''); + } + + if ( $base and $base->billpkgnum ) { + $link->set('base_billpkgnum', $base->billpkgnum); + $link->set('base_cust_bill_pkg', ''); + } elsif ( $base ) { + # it's based on a line item that's not yet inserted + my $link_array = $base->get('cust_bill_pkg_fee') || []; + push @$link_array, $link; + $base->set('cust_bill_pkg_fee' => $link_array); + next; # don't insert the link yet + } + + $error = $link->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error inserting cust_bill_pkg_fee: $error"; + } + } # foreach my $link + } + + my $cust_event_fee = $self->get('cust_event_fee'); + if ( $cust_event_fee ) { + $cust_event_fee->set('billpkgnum' => $self->billpkgnum); + $error = $cust_event_fee->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error updating cust_event_fee: $error"; + } + } + my $cust_tax_adjustment = $self->get('cust_tax_adjustment'); if ( $cust_tax_adjustment ) { $cust_tax_adjustment->billpkgnum($self->billpkgnum); @@ -903,6 +949,50 @@ sub credited { $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum); } +=item tax_locationnum + +Returns the L number that this line item is in for tax +purposes. For package sales, it's the package tax location; for fees, +it's the customer's default service location. + +=cut + +sub tax_locationnum { + my $self = shift; + if ( $self->pkgnum ) { # normal sales + return $self->cust_pkg->tax_locationnum; + } elsif ( $self->feepart ) { # fees + return $self->cust_bill->cust_main->ship_locationnum; + } else { # taxes + return ''; + } +} + +sub tax_location { + my $self = shift; + FS::cust_location->by_key($self->tax_locationnum); +} + +=item part_X + +Returns the L or L object that defines this +charge. If called on a tax line, returns nothing. + +=cut + +sub part_X { + my $self = shift; + if ( $self->override_pkgpart ) { + return FS::part_pkg->by_key($self->override_pkgpart); + } elsif ( $self->pkgnum ) { + return $self->cust_pkg->part_pkg; + } elsif ( $self->feepart ) { + return $self->part_fee; + } else { + return; + } +} + =back =head1 CLASS METHODS @@ -926,9 +1016,10 @@ sub usage_sql { $usage_sql } # this makes owed_sql, etc. much more concise sub charged_sql { my ($class, $start, $end, %opt) = @_; + my $setuprecur = $opt{setuprecur} || ''; my $charged = - $opt{setuprecur} =~ /^s/ ? 'cust_bill_pkg.setup' : - $opt{setuprecur} =~ /^r/ ? 'cust_bill_pkg.recur' : + $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' : + $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' : 'cust_bill_pkg.setup + cust_bill_pkg.recur'; if ($opt{no_usage} and $charged =~ /recur/) { @@ -964,10 +1055,9 @@ sub paid_sql { my ($class, $start, $end, %opt) = @_; my $s = $start ? "AND cust_pay._date <= $start" : ''; my $e = $end ? "AND cust_pay._date > $end" : ''; - my $setuprecur = - $opt{setuprecur} =~ /^s/ ? 'setup' : - $opt{setuprecur} =~ /^r/ ? 'recur' : - ''; + my $setuprecur = $opt{setuprecur} || ''; + $setuprecur = 'setup' if $setuprecur =~ /^s/; + $setuprecur = 'recur' if $setuprecur =~ /^r/; $setuprecur &&= "AND setuprecur = '$setuprecur'"; my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0) @@ -993,10 +1083,9 @@ sub credited_sql { my ($class, $start, $end, %opt) = @_; my $s = $start ? "AND cust_credit._date <= $start" : ''; my $e = $end ? "AND cust_credit._date > $end" : ''; - my $setuprecur = - $opt{setuprecur} =~ /^s/ ? 'setup' : - $opt{setuprecur} =~ /^r/ ? 'recur' : - ''; + my $setuprecur = $opt{setuprecur} || ''; + $setuprecur = 'setup' if $setuprecur =~ /^s/; + $setuprecur = 'recur' if $setuprecur =~ /^r/; $setuprecur &&= "AND setuprecur = '$setuprecur'"; my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0) diff --git a/FS/FS/cust_bill_pkg_fee.pm b/FS/FS/cust_bill_pkg_fee.pm new file mode 100644 index 000000000..8ea73c9dc --- /dev/null +++ b/FS/FS/cust_bill_pkg_fee.pm @@ -0,0 +1,91 @@ +package FS::cust_bill_pkg_fee; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_bill_pkg_fee - Object methods for cust_bill_pkg_fee records + +=head1 SYNOPSIS + + use FS::cust_bill_pkg_fee; + + $record = new FS::cust_bill_pkg_fee \%hash; + $record = new FS::cust_bill_pkg_fee { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_bill_pkg_fee object records the origin of a fee. +. FS::cust_bill_pkg_fee inherits from +FS::Record. The following fields are currently supported: + +=over 4 + +=item billpkgfeenum - primary key + +=item billpkgnum - the billpkgnum of the fee line item + +=item base_invnum - the invoice number (L) that caused +(this portion of) the fee to be charged. + +=item base_billpkgnum - the invoice line item (L) that +caused (this portion of) the fee to be charged. May be null. + +=item amount - the fee amount + +=back + +=head1 METHODS + +=over 4 + +=cut + +sub table { 'cust_bill_pkg_fee'; } + +# seeing as these methods are not defined in this module I object to having +# perldoc noise for them + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('billpkgfeenum') + || $self->ut_number('billpkgnum') + || $self->ut_foreign_key('origin_invnum', 'cust_bill', 'invnum') + || $self->ut_foreign_keyn('origin_billpkgnum', 'cust_bill_pkg', 'billpkgnum') + || $self->ut_money('amount') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 SEE ALSO + +L + +=cut + +1; + diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm index 7ae6c97a5..189084525 100644 --- a/FS/FS/cust_credit.pm +++ b/FS/FS/cust_credit.pm @@ -910,14 +910,9 @@ sub credit_lineitems { # recalculate taxes with new amounts $taxlisthash{$invnum} ||= {}; - my $part_pkg = $cust_bill_pkg->part_pkg; - $cust_main->_handle_taxes( $part_pkg, - $taxlisthash{$invnum}, - $cust_bill_pkg, - $cust_bill_pkg->cust_pkg, - $cust_bill_pkg->cust_bill->_date, #invoice time - $cust_bill_pkg->cust_pkg->pkgpart, - ); + my $part_pkg = $cust_bill_pkg->part_pkg + if $cust_bill_pkg->pkgpart_override; + $cust_main->_handle_taxes( $taxlisthash{$invnum}, $cust_bill_pkg ); } ### @@ -1013,12 +1008,12 @@ sub credit_lineitems { # we still have to deal with the possibility that the tax links don't # cover the whole amount of tax because of an incomplete upgrade... - if ($amount > 0) { + if ($amount > 0.005) { $cust_credit_bill{$invnum} += $amount; push @{ $cust_credit_bill_pkg{$invnum} }, new FS::cust_credit_bill_pkg { 'billpkgnum' => $tax_item->billpkgnum, - 'amount' => $amount, + 'amount' => sprintf('%.2f', $amount), 'setuprecur' => 'setup', }; diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm new file mode 100644 index 000000000..78794fdfe --- /dev/null +++ b/FS/FS/cust_event_fee.pm @@ -0,0 +1,158 @@ +package FS::cust_event_fee; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_event_fee - Object methods for cust_event_fee records + +=head1 SYNOPSIS + + use FS::cust_event_fee; + + $record = new FS::cust_event_fee \%hash; + $record = new FS::cust_event_fee { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_event_fee object links a billing event that charged a fee +(an L) to the resulting invoice line item (an +L object). FS::cust_event_fee inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item eventfeenum - primary key + +=item eventnum - key of the cust_event record that required the fee to be +created. This is a unique column; there's no reason for a single event +instance to create more than one fee. + +=item billpkgnum - key of the cust_bill_pkg record representing the fee +on an invoice. This is also a unique column but can be NULL to indicate +a fee that hasn't been billed yet. In that case it will be billed the next +time billing runs for the customer. + +=item feepart - key of the fee definition (L). + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new event-fee link. To add the record to the database, +see L<"insert">. + +=cut + +sub table { 'cust_event_fee'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('eventfeenum') + || $self->ut_foreign_key('eventnum', 'cust_event', 'eventnum') + || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg', 'billpkgnum') + || $self->ut_foreign_key('feepart', 'part_fee', 'feepart') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 CLASS METHODS + +=over 4 + +=item by_cust CUSTNUM[, PARAMS] + +Finds all cust_event_fee records belonging to the customer CUSTNUM. Currently +fee events can be cust_main or cust_bill events; this will return both. + +PARAMS can be additional params to pass to qsearch; this really only works +for 'hashref' and 'order_by'. + +=cut + +sub by_cust { + my $class = shift; + my $custnum = shift or return; + my %params = @_; + $custnum =~ /^\d+$/ or die "bad custnum $custnum"; + + # silliness + my $where = ($params{hashref} && keys (%{ $params{hashref} })) + ? 'AND' + : 'WHERE'; + qsearch({ + table => 'cust_event_fee', + addl_from => 'JOIN cust_event USING (eventnum) ' . + 'JOIN part_event USING (eventpart) ', + extra_sql => "$where eventtable = 'cust_main' ". + "AND cust_event.tablenum = $custnum", + %params + }), + qsearch({ + table => 'cust_event_fee', + addl_from => 'JOIN cust_event USING (eventnum) ' . + 'JOIN part_event USING (eventpart) ' . + 'JOIN cust_bill ON (cust_event.tablenum = cust_bill.invnum)', + extra_sql => "$where eventtable = 'cust_bill' ". + "AND cust_bill.custnum = $custnum", + %params + }) +} + + + +=head1 BUGS + +=head1 SEE ALSO + +L, L, L + +=cut + +1; + diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index b8a71d4a9..f4c30ce63 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -21,6 +21,7 @@ use FS::cust_bill_pkg_tax_rate_location; use FS::part_event; use FS::part_event_condition; use FS::pkg_category; +use FS::cust_event_fee; use FS::Log; # 1 is mostly method/subroutine entry and options @@ -538,6 +539,72 @@ sub bill { #.Dumper(\@cust_bill_pkg)."\n" if $DEBUG > 2; + ### + # process fees + ### + + my @pending_event_fees = FS::cust_event_fee->by_cust($self->custnum, + hashref => { 'billpkgnum' => '' } + ); + warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n" + if @pending_event_fees; + + my @fee_items; + foreach my $event_fee (@pending_event_fees) { + my $object = $event_fee->cust_event->cust_X; + my $cust_bill; + if ( $object->isa('FS::cust_main') ) { + # Not the real cust_bill object that will be inserted--in particular + # there are no taxes yet. If you want to charge a fee on the total + # invoice amount including taxes, you have to put the fee on the next + # invoice. + $cust_bill = FS::cust_bill->new({ + 'custnum' => $self->custnum, + 'cust_bill_pkg' => \@cust_bill_pkg, + 'charged' => ${ $total_setup{$pass} } + + ${ $total_recur{$pass} }, + }); + } elsif ( $object->isa('FS::cust_bill') ) { + # simple case: applying the fee to a previous invoice (late fee, + # etc.) + $cust_bill = $object; + } + my $part_fee = $event_fee->part_fee; + # if the fee def belongs to a different agent, don't charge the fee. + # event conditions should prevent this, but just in case they don't, + # skip the fee. + if ( $part_fee->agentnum and $part_fee->agentnum != $self->agentnum ) { + warn "tried to charge fee#".$part_fee->feepart . + " on customer#".$self->custnum." from a different agent.\n"; + next; + } + # also skip if it's disabled + next if $part_fee->disabled eq 'Y'; + # calculate the fee + my $fee_item = $event_fee->part_fee->lineitem($cust_bill); + # link this so that we can clear the marker on inserting the line item + $fee_item->set('cust_event_fee', $event_fee); + push @fee_items, $fee_item; + } + foreach my $fee_item (@fee_items) { + + push @cust_bill_pkg, $fee_item; + ${ $total_setup{$pass} } += $fee_item->setup; + ${ $total_recur{$pass} } += $fee_item->recur; + + my $part_fee = $fee_item->part_fee; + my $fee_location = $self->ship_location; # I think? + + my $error = $self->_handle_taxes( + $taxlisthash{$pass}, + $fee_item, + location => $fee_location + ); + return $error if $error; + + } + + # XXX implementation of fees is supposed to make this go away... if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) || !$conf->exists('postal_invoice-recurring_only') ) @@ -633,14 +700,12 @@ sub bill { my @cust_bill = $self->cust_bill; my $balance = $self->balance; - my $previous_balance = scalar(@cust_bill) - ? ( $cust_bill[$#cust_bill]->billing_balance || 0 ) - : 0; - - $previous_balance += $cust_bill[$#cust_bill]->charged - if scalar(@cust_bill); - #my $balance_adjustments = - # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged); + my $previous_bill = $cust_bill[-1] if @cust_bill; + my $previous_balance = 0; + if ( $previous_bill ) { + $previous_balance = $previous_bill->billing_balance + + $previous_bill->charged; + } warn "creating the new invoice\n" if $DEBUG; #create the new invoice @@ -935,6 +1000,7 @@ sub _make_lines { my $part_pkg = $params{part_pkg} or die "no part_pkg specified"; my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified"; + my $cust_location = $cust_pkg->tax_location; my $precommit_hooks = $params{precommit_hooks} or die "no precommit_hooks specified"; my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified"; my $total_setup = $params{setup} or die "no setup accumulator specified"; @@ -1250,18 +1316,8 @@ sub _make_lines { # handle taxes ### - #unless ( $discount_show_always ) { # oh, for god's sake - my $error = $self->_handle_taxes( - $part_pkg, - $taxlisthash, - $cust_bill_pkg, - $cust_pkg, - $options{invoice_time}, - $real_pkgpart, - \%options # I have serious objections to this - ); + my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg ); return $error if $error; - #} $cust_bill_pkg->set_display( part_pkg => $part_pkg, @@ -1357,15 +1413,13 @@ sub _transfer_balance { return @transfers; } -=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ] +=item handle_taxes TAXLISTHASH CUST_BILL_PKG [ OPTIONS ] This is _handle_taxes. It's called once for each cust_bill_pkg generated -from _make_lines, along with the part_pkg, cust_pkg, invoice time, the -non-overridden pkgpart, a flag indicating whether the package is being -canceled, and a partridge in a pear tree. +from _make_lines. -The most important argument is 'taxlisthash'. This is shared across the -entire invoice. It looks like this: +TAXLISTHASH is a hashref shared across the entire invoice. It looks like +this: { 'cust_main_county 1001' => [ [FS::cust_main_county], ... ], 'cust_main_county 1002' => [ [FS::cust_main_county], ... ], @@ -1378,27 +1432,35 @@ That "..." is a list of FS::cust_bill_pkg objects that will be fed to the 'taxline' method to calculate the amount of the tax. This doesn't happen until calculate_taxes, though. +OPTIONS may include: +- part_item: a part_pkg or part_fee object to be used as the package/fee + definition. +- location: a cust_location to be used as the billing location. + +If not supplied, part_item will be inferred from the pkgnum or feepart of the +cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and +the customer's default service location). + =cut sub _handle_taxes { my $self = shift; - my $part_pkg = shift; my $taxlisthash = shift; my $cust_bill_pkg = shift; - my $cust_pkg = shift; - my $invoice_time = shift; - my $real_pkgpart = shift; - my $options = shift; + my %options = @_; - local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + # at this point I realize that we have enough information to infer all this + # stuff, instead of passing around giant honking argument lists + my $location = $options{location} || $cust_bill_pkg->tax_location; + my $part_item = $options{part_item} || $cust_bill_pkg->part_X; - my $location = $cust_pkg->tax_location; + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; return if ( $self->payby eq 'COMP' ); #dubious if ( $conf->exists('enable_taxproducts') - && ( scalar($part_pkg->part_pkg_taxoverride) - || $part_pkg->has_taxproduct + && ( scalar($part_item->part_pkg_taxoverride) + || $part_item->has_taxproduct ) ) { @@ -1410,9 +1472,8 @@ sub _handle_taxes { my @classes; #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; - # debatable - push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); - push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); + push @classes, 'setup' if $cust_bill_pkg->setup; + push @classes, 'recur' if $cust_bill_pkg->recur; my $exempt = $conf->exists('cust_class-tax_exempt') ? ( $self->cust_class ? $self->cust_class->tax : '' ) @@ -1423,13 +1484,13 @@ sub _handle_taxes { if ( !$exempt ) { foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg ); + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); return $err_or_ref unless ref($err_or_ref); $taxes{$class} = $err_or_ref; } unless (exists $taxes{''}) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg ); + my $err_or_ref = $self->_gather_taxes($part_item, '', $location); return $err_or_ref unless ref($err_or_ref); $taxes{''} = $err_or_ref; } @@ -1480,10 +1541,7 @@ sub _handle_taxes { warn "adding $totname to taxed taxes\n" if $DEBUG > 2; # calculate the tax amount that the tax_on_tax will apply to my $hashref_or_error = - $tax_object->taxline( $localtaxlisthash{$tax}, - 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time, - ); + $tax_object->taxline( $localtaxlisthash{$tax} ); return $hashref_or_error unless ref($hashref_or_error); @@ -1505,7 +1563,7 @@ sub _handle_taxes { my @loc_keys = qw( district city county state country ); my %taxhash = map { $_ => $location->$_ } @loc_keys; - $taxhash{'taxclass'} = $part_pkg->taxclass; + $taxhash{'taxclass'} = $part_item->taxclass; warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; @@ -1538,44 +1596,28 @@ sub _handle_taxes { ''; } +=item _gather_taxes PART_ITEM CLASS CUST_LOCATION + +Internal method used with vendor-provided tax tables. PART_ITEM is a part_pkg +or part_fee (which will define the tax eligibility of the product), CLASS is +'setup', 'recur', null, or a C number, and CUST_LOCATION is the +location where the service was provided (or billed, depending on +configuration). Returns an arrayref of L objects that +can apply to this line item. + +=cut + sub _gather_taxes { my $self = shift; - my $part_pkg = shift; + my $part_item = shift; my $class = shift; - my $cust_pkg = shift; + my $location = shift; local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; - my $geocode = $cust_pkg->tax_location->geocode('cch'); - - my @taxes = (); - - my @taxclassnums = map { $_->taxclassnum } - $part_pkg->part_pkg_taxoverride($class); - - unless (@taxclassnums) { - @taxclassnums = map { $_->taxclassnum } - grep { $_->taxable eq 'Y' } - $part_pkg->part_pkg_taxrate('cch', $geocode, $class); - } - warn "Found taxclassnum values of ". join(',', @taxclassnums) - if $DEBUG; - - my $extra_sql = - "AND (". - join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; - - @taxes = qsearch({ 'table' => 'tax_rate', - 'hashref' => { 'geocode' => $geocode, }, - 'extra_sql' => $extra_sql, - }) - if scalar(@taxclassnums); - - warn "Found taxes ". - join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" - if $DEBUG; + my $geocode = $location->geocode('cch'); - [ @taxes ]; + [ $part_item->tax_rates('cch', $geocode, $class) ] } @@ -2424,6 +2466,7 @@ sub apply_payments { _handle_taxes (vendor-only) _gather_taxes _omit_zero_value_bundles + _handle_taxes (for fees) calculate_taxes apply_payments_and_credits diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm index 5c1be7b46..654e56749 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -316,6 +316,11 @@ sub taxline { my $cust_pkg = $cust_bill_pkg->cust_pkg; my $part_pkg = $cust_bill_pkg->part_pkg; + my $part_fee = $cust_bill_pkg->part_fee; + + my $locationnum = $cust_pkg + ? $cust_pkg->locationnum + : $cust_main->bill_locationnum; my @new_exemptions; my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur @@ -341,8 +346,13 @@ sub taxline { } - if ( ($part_pkg->setuptax eq 'Y' or $self->setuptax eq 'Y') - and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) { + my $setup_exempt = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->setuptax) + or $self->setuptax ); + + if ( $setup_exempt + and $cust_bill_pkg->setup > 0 + and $taxable_charged > 0 ) { push @new_exemptions, FS::cust_tax_exempt_pkg->new({ amount => $cust_bill_pkg->setup, @@ -351,8 +361,14 @@ sub taxline { $taxable_charged -= $cust_bill_pkg->setup; } - if ( ($part_pkg->recurtax eq 'Y' or $self->recurtax eq 'Y') - and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) { + + my $recur_exempt = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->recurtax) + or $self->recurtax ); + + if ( $recur_exempt + and $cust_bill_pkg->recur > 0 + and $taxable_charged > 0 ) { push @new_exemptions, FS::cust_tax_exempt_pkg->new({ amount => $cust_bill_pkg->recur, @@ -494,7 +510,7 @@ sub taxline { 'taxtype' => ref($self), 'cents' => $this_tax_cents, 'pkgnum' => $cust_bill_pkg->pkgnum, - 'locationnum' => $cust_bill_pkg->cust_pkg->tax_locationnum, + 'locationnum' => $locationnum, 'taxable_cust_bill_pkg' => $cust_bill_pkg, 'tax_cust_bill_pkg' => $tax_item, }); diff --git a/FS/FS/option_Common.pm b/FS/FS/option_Common.pm index c1dda22af..74adbede8 100644 --- a/FS/FS/option_Common.pm +++ b/FS/FS/option_Common.pm @@ -134,13 +134,7 @@ sub delete { my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; - - my $error = $self->SUPER::delete; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - + my $pkey = $self->primary_key; #my $option_table = $self->option_table; @@ -152,6 +146,12 @@ sub delete { } } + my $error = $self->SUPER::delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; diff --git a/FS/FS/part_event/Action/Mixin/fee.pm b/FS/FS/part_event/Action/Mixin/fee.pm new file mode 100644 index 000000000..8eb86fa1d --- /dev/null +++ b/FS/FS/part_event/Action/Mixin/fee.pm @@ -0,0 +1,45 @@ +package FS::part_event::Action::Mixin::fee; + +use strict; +use base qw( FS::part_event::Action ); + +sub event_stage { 'pre-bill'; } + +sub option_fields { + ( + 'feepart' => { label => 'Fee definition', + type => 'select-table', #select-part_fee XXX + table => 'part_fee', + hashref => { disabled => '' }, + name_col => 'itemdesc', + value_col => 'feepart', + disable_empty => 1, + }, + ); +} + +sub default_weight { 10; } + +sub do_action { + my( $self, $cust_object, $cust_event ) = @_; + + die "no fee definition selected for event '".$self->event."'\n" + unless $self->option('feepart'); + + # mark the event so that the fee will be charged + # the logic for calculating the fee amount is in FS::part_fee + # the logic for attaching it to the base invoice/line items is in + # FS::cust_bill_pkg + my $cust_event_fee = FS::cust_event_fee->new({ + 'eventnum' => $cust_event->eventnum, + 'feepart' => $self->option('feepart'), + 'billpkgnum' => '', + }); + + my $error = $cust_event_fee->insert; + die $error if $error; + + ''; +} + +1; diff --git a/FS/FS/part_event/Action/cust_bill_fee.pm b/FS/FS/part_event/Action/cust_bill_fee.pm new file mode 100644 index 000000000..fc185e439 --- /dev/null +++ b/FS/FS/part_event/Action/cust_bill_fee.pm @@ -0,0 +1,12 @@ +package FS::part_event::Action::cust_bill_fee; + +use strict; +use base qw( FS::part_event::Action::Mixin::fee ); + +sub description { 'Charge a fee based on this invoice'; } + +sub eventtable_hashref { + { 'cust_bill' => 1 }; +} + +1; diff --git a/FS/FS/part_event/Action/cust_fee.pm b/FS/FS/part_event/Action/cust_fee.pm new file mode 100644 index 000000000..a6f1078e8 --- /dev/null +++ b/FS/FS/part_event/Action/cust_fee.pm @@ -0,0 +1,16 @@ +package FS::part_event::Action::cust_fee; + +use strict; +use base qw( FS::part_event::Action::Mixin::fee ); + +sub description { 'Charge a fee based on the customer\'s current invoice'; } + +sub eventtable_hashref { + { 'cust_main' => 1 }; +} + +# Otherwise identical to cust_bill_fee. We only have a separate event +# because it behaves differently as an invoice event than as a customer +# event, and needs a different description. + +1; diff --git a/FS/FS/part_event/Action/fee.pm b/FS/FS/part_event/Action/fee.pm index c2b4673fa..f1d5891ac 100644 --- a/FS/FS/part_event/Action/fee.pm +++ b/FS/FS/part_event/Action/fee.pm @@ -1,5 +1,7 @@ package FS::part_event::Action::fee; +# DEPRECATED; will most likely be removed in 4.x + use strict; use base qw( FS::part_event::Action ); @@ -53,11 +55,9 @@ sub _calc_fee { 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}), + my $error = $cust_main->_handle_taxes( $taxlisthash, $charge, + location => $cust_main->ship_location, + part_item => $part_pkg, ); if ( $error ) { warn "error estimating taxes for breakage charge: custnum ".$cust_main->custnum."\n"; diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm index 8e10ea712..9d261f02d 100644 --- a/FS/FS/part_export.pm +++ b/FS/FS/part_export.pm @@ -161,6 +161,10 @@ sub delete { 'link_table' => 'export_nas', 'target_table' => 'nas', 'params' => [], + ) || $self->process_m2m( + 'link_table' => 'export_svc', + 'target_table' => 'part_svc', + 'params' => [], ) || $self->SUPER::delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm new file mode 100644 index 000000000..9605d61d2 --- /dev/null +++ b/FS/FS/part_fee.pm @@ -0,0 +1,464 @@ +package FS::part_fee; + +use strict; +use base qw( FS::o2m_Common FS::Record ); +use vars qw( $DEBUG ); +use FS::Record qw( qsearch qsearchs ); + +$DEBUG = 1; + +=head1 NAME + +FS::part_fee - Object methods for part_fee records + +=head1 SYNOPSIS + + use FS::part_fee; + + $record = new FS::part_fee \%hash; + $record = new FS::part_fee { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::part_fee object represents the definition of a fee + +Fees are like packages, but instead of being ordered and then billed on a +cycle, they are created by the operation of events and added to a single +invoice. The fee definition specifies the fee's description, how the amount +is calculated (a flat fee or a percentage of the customer's balance), and +how to classify the fee for tax and reporting purposes. + +FS::part_fee inherits from FS::Record. The following fields are currently +supported: + +=over 4 + +=item feepart - primary key + +=item comment - a description of the fee for employee use, not shown on +the invoice + +=item disabled - 'Y' if the fee is disabled + +=item classnum - the L that the fee belongs to, for reporting + +=item taxable - 'Y' if this fee should be considered a taxable sale. +Currently, taxable fees will be treated like they exist at the customer's +default service location. + +=item taxclass - the tax class the fee belongs to, as a string, for the +internal tax system + +=item taxproductnum - the tax product family the fee belongs to, for the +external tax system in use, if any + +=item pay_weight - Weight (relative to credit_weight and other package/fee +definitions) that controls payment application to specific line items. + +=item credit_weight - Weight that controls credit application to specific +line items. + +=item agentnum - the agent (L) who uses this fee definition. + +=item amount - the flat fee to charge, as a decimal amount + +=item percent - the percentage of the base to charge (out of 100). If both +this and "amount" are specified, the fee will be the sum of the two. + +=item basis - the method for calculating the base: currently one of "charged", +"owed", or null. + +=item minimum - the minimum fee that should be charged + +=item maximum - the maximum fee that should be charged + +=item limit_credit - 'Y' to set the maximum fee at the customer's credit +balance, if any. + +=item setuprecur - whether the fee should be classified as 'setup' or +'recur', for reporting purposes. + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new fee definition. To add the record to the database, see +L<"insert">. + +=cut + +sub table { 'part_fee'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + $self->set('amount', 0) unless $self->amount; + + my $error = + $self->ut_numbern('feepart') + || $self->ut_textn('comment') + || $self->ut_flag('disabled') + || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum') + || $self->ut_flag('taxable') + || $self->ut_textn('taxclass') + || $self->ut_numbern('taxproductnum') + || $self->ut_floatn('pay_weight') + || $self->ut_floatn('credit_weight') + || $self->ut_agentnum_acl('agentnum', + [ 'Edit global package definitions' ]) + || $self->ut_moneyn('amount') + || $self->ut_floatn('percent') + || $self->ut_moneyn('minimum') + || $self->ut_moneyn('maximum') + || $self->ut_flag('limit_credit') + || $self->ut_enum('basis', [ '', 'charged', 'owed' ]) + || $self->ut_enum('setuprecur', [ 'setup', 'recur' ]) + ; + return $error if $error; + + return "For a percentage fee, the basis must be set" + if $self->get('percent') > 0 and $self->get('basis') eq ''; + + if ( ! $self->get('percent') and ! $self->get('limit_credit') ) { + # then it makes no sense to apply minimum/maximum + $self->set('minimum', ''); + $self->set('maximum', ''); + } + if ( $self->get('limit_credit') ) { + $self->set('maximum', ''); + } + + $self->SUPER::check; +} + +=item explanation + +Returns a string describing how this fee is calculated. + +=cut + +sub explanation { + my $self = shift; + # XXX customer currency + my $money_char = FS::Conf->new->config('money_char') || '$'; + my $money = $money_char . '%.2f'; + my $percent = '%.1f%%'; + my $string; + if ( $self->amount > 0 ) { + $string = sprintf($money, $self->amount); + } + if ( $self->percent > 0 ) { + if ( $string ) { + $string .= " plus "; + } + $string .= sprintf($percent, $self->percent); + $string .= ' of the '; + if ( $self->basis eq 'charged' ) { + $string .= 'invoice amount'; + } elsif ( $self->basis('owed') ) { + $string .= 'unpaid invoice balance'; + } + } + if ( $self->minimum or $self->maximum or $self->limit_credit ) { + $string .= "\nbut"; + if ( $self->minimum ) { + $string .= ' at least '.sprintf($money, $self->minimum); + } + if ( $self->maximum ) { + $string .= ' and' if $self->minimum; + $string .= ' at most '.sprintf($money, $self->maximum); + } + if ( $self->limit_credit ) { + if ( $self->maximum ) { + $string .= ", or the customer's credit balance, whichever is less."; + } else { + $string .= ' and' if $self->minimum; + $string .= " not more than the customer's credit balance"; + } + } + } + return $string; +} + +=item lineitem INVOICE + +Given INVOICE (an L), returns an L object +representing the invoice line item for the fee, with linked +L record(s) allocating the fee to the invoice or +its line items, as appropriate. + +If the fee is going to be charged on the upcoming invoice (credit card +processing fees, postal invoice fees), INVOICE should be an uninserted +L object where the 'cust_bill_pkg' property is an arrayref +of the non-fee line items that will appear on the invoice. + +=cut + +sub lineitem { + my $self = shift; + my $cust_bill = shift; + my $cust_main = $cust_bill->cust_main; + + my $amount = 0 + $self->get('amount'); + my $total_base; # sum of base line items + my @items; # base line items (cust_bill_pkg records) + my @item_base; # charged/owed of that item (sequential w/ @items) + my @item_fee; # fee amount of that item (sequential w/ @items) + my @cust_bill_pkg_fee; # link record + + warn "Calculating fee: ".$self->itemdesc." on ". + ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice"). + "\n" if $DEBUG; + if ( $self->percent > 0 and $self->basis ne '' ) { + warn $self->percent . "% of amount ".$self->basis.")\n" + if $DEBUG; + + # $total_base: the total charged/owed on the invoice + # %item_base: billpkgnum => fraction of base amount + if ( $cust_bill->invnum ) { + my $basis = $self->basis; + $total_base = $cust_bill->$basis; # "charged", "owed" + + # calculate the fee on an already-inserted past invoice. This may have + # payments or credits, so if basis = owed, we need to consider those. + my $basis_sql = $basis.'_sql'; + my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql . + ' FROM cust_bill_pkg WHERE billpkgnum = ?'; + @items = $cust_bill->cust_bill_pkg; + @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) } + @items; + } else { + # the fee applies to _this_ invoice. It has no payments or credits, so + # "charged" and "owed" basis are both just the invoice amount, and + # the line item amounts (setup + recur) + $total_base = $cust_bill->charged; + @items = @{ $cust_bill->get('cust_bill_pkg') }; + @item_base = map { $_->setup + $_->recur } + @items; + } + + $amount += $total_base * $self->percent / 100; + } + + if ( $self->minimum ne '' and $amount < $self->minimum ) { + warn "Applying mininum fee\n" if $DEBUG; + $amount = $self->minimum; + } + + my $maximum = $self->maximum; + if ( $self->limit_credit ) { + my $balance = $cust_bill->cust_main->balance; + if ( $balance >= 0 ) { + warn "Credit balance is zero, so fee is zero" if $DEBUG; + return; # don't bother doing estimated tax, etc. + } elsif ( -1 * $balance < $maximum ) { + $maximum = -1 * $balance; + } + } + if ( $maximum ne '' ) { + warn "Applying maximum fee\n" if $DEBUG; + $amount = $maximum; + } + + # at this point, if the fee is zero, return nothing + return if $amount < 0.005; + $amount = sprintf('%.2f', $amount); + + my $cust_bill_pkg = FS::cust_bill_pkg->new({ + feepart => $self->feepart, + pkgnum => 0, + # no sdate/edate, right? + setup => 0, + recur => 0, + }); + + if ( $maximum and $self->taxable ) { + warn "Estimating taxes on fee.\n"; + # then we need to estimate tax to respect the maximum + # XXX currently doesn't work with external (tax_rate) taxes + # or batch taxes, obviously + my $taxlisthash = {}; + my $error = $cust_main->_handle_taxes( + $taxlisthash, + $cust_bill_pkg, + location => $cust_main->ship_location + ); + my $total_rate = 0; + # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ] + my @taxes = map { $_->[0] } values %$taxlisthash; + foreach (@taxes) { + $total_rate += $_->tax; + } + if ($total_rate > 0) { + my $max_cents = $maximum * 100; + my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate)); + $maximum = sprintf('%.2f', $charge_cents / 100.00); + $amount = $maximum if $amount > $maximum; + } + } # if $maximum and $self->taxable + + # set the amount that we'll charge + $cust_bill_pkg->set( $self->setuprecur, $amount ); + + if ( $self->classnum ) { + my $pkg_category = $self->pkg_class->pkg_category; + $cust_bill_pkg->set('section' => $pkg_category->categoryname) + if $pkg_category; + } + + # if this is a percentage fee and has line item fractions, + # adjust them to be proportional and to add up correctly. + if ( @item_base ) { + my $cents = $amount * 100; + # not necessarily the same as percent + my $multiplier = $amount / $total_base; + for (my $i = 0; $i < scalar(@items); $i++) { + my $fee = sprintf('%.2f', $item_base[$i] * $multiplier); + $item_fee[$i] = $fee; + $cents -= $fee * 100; + } + # correct rounding error + while ($cents >= 0.5 or $cents < -0.5) { + foreach my $fee (@item_fee) { + if ( $cents >= 0.5 ) { + $fee += 0.01; + $cents--; + } elsif ( $cents < -0.5 ) { + $fee -= 0.01; + $cents++; + } + } + } + # and add them to the cust_bill_pkg + for (my $i = 0; $i < scalar(@items); $i++) { + if ( $item_fee[$i] > 0 ) { + push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({ + cust_bill_pkg => $cust_bill_pkg, + base_invnum => $cust_bill->invnum, + amount => $item_fee[$i], + base_cust_bill_pkg => $items[$i], # late resolve + }); + } + } + } else { # if !@item_base + # then this isn't a proportional fee, so it just applies to the + # entire invoice. + # (if it's the current invoice, $cust_bill->invnum is null and that + # will be fixed later) + push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({ + cust_bill_pkg => $cust_bill_pkg, + base_invnum => $cust_bill->invnum, + amount => $amount, + }); + } + + # cust_bill_pkg::insert will handle this + $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee); + # avoid misbehavior by usage() and some other things + $cust_bill_pkg->set('details', []); + + return $cust_bill_pkg; +} + +=item itemdesc_locale LOCALE + +Returns a customer-viewable description of this fee for the given locale, +from the part_fee_msgcat table. If the locale is empty or no localized fee +description exists, returns part_fee.itemdesc. + +=cut + +sub itemdesc_locale { + my ( $self, $locale ) = @_; + return $self->itemdesc unless $locale; + my $part_fee_msgcat = qsearchs('part_fee_msgcat', { + feepart => $self->feepart, + locale => $locale, + }) or return $self->itemdesc; + $part_fee_msgcat->itemdesc; +} + +=item tax_rates DATA_PROVIDER, GEOCODE + +Returns the external taxes (L objects) that apply to this +fee, in the location specified by GEOCODE. + +=cut + +sub tax_rates { + my $self = shift; + my ($vendor, $geocode) = @_; + return unless $self->taxproductnum; + my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum); + # cch stuff + my @taxclassnums = map { $_->taxclassnum } + $taxproduct->part_pkg_taxrate($geocode); + return unless @taxclassnums; + + warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n" + if $DEBUG; + my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")"; + my @taxes = qsearch({ 'table' => 'tax_rate', + 'hashref' => { 'geocode' => $geocode, + 'data_vendor' => $vendor }, + 'extra_sql' => $extra_sql, + }); + warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n" + if $DEBUG; + + return @taxes; +} + +sub part_pkg_taxoverride {} # we don't do overrides here + +sub has_taxproduct { + my $self = shift; + return ($self->taxproductnum ? 1 : 0); +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L + +=cut + +1; + diff --git a/FS/FS/part_fee_msgcat.pm b/FS/FS/part_fee_msgcat.pm new file mode 100644 index 000000000..e60651e0e --- /dev/null +++ b/FS/FS/part_fee_msgcat.pm @@ -0,0 +1,127 @@ +package FS::part_fee_msgcat; +use base qw( FS::Record ); + +use strict; +use FS::Locales; + +=head1 NAME + +FS::part_fee_msgcat - Object methods for part_fee_msgcat records + +=head1 SYNOPSIS + + use FS::part_fee_msgcat; + + $record = new FS::part_fee_msgcat \%hash; + $record = new FS::part_fee_msgcat { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::part_fee_msgcat object represents localized labels of a fee +definition. FS::part_fee_msgcat inherits from FS::Record. The following +fields are currently supported: + +=over 4 + +=item feepartmsgnum + +primary key + +=item feepart - Fee definition (L) + +=item locale - locale string + +=item itemdesc - Localized fee name (customer-viewable) + +=item comment - Localized fee comment (non-customer-viewable), optional + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'part_fee_msgcat'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('feepartmsgnum') + || $self->ut_foreign_key('feepart', 'part_fee', 'feepart') + || $self->ut_enum('locale', [ FS::Locales->locales ] ) + || $self->ut_text('itemdesc') + || $self->ut_textn('comment') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +Exactly duplicates part_pkg_msgcat.pm. + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 8f8287b5f..e872232a8 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -1463,74 +1463,40 @@ sub taxproduct_description { $part_pkg_taxproduct ? $part_pkg_taxproduct->description : ''; } -=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ] -Returns the package to taxrate m2m records for this package in the location -specified by GEOCODE (see L) and usage class CLASS. -CLASS may be one of 'setup', 'recur', or one of the usage classes numbers -(see L). +=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ] -=cut +Returns the tax table entries (L objects) that apply to this +package in the location specified by GEOCODE, for usage class CLASS (one of +'setup', 'recur', null, or a C number). -sub _expand_cch_taxproductnum { - my $self = shift; - my $class = shift; - my $part_pkg_taxproduct = $self->taxproduct($class); - - my ($a,$b,$c,$d) = ( $part_pkg_taxproduct - ? ( split ':', $part_pkg_taxproduct->taxproduct ) - : () - ); - $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d; - my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d' - OR taxproduct = '$a:$b:$c:' - OR taxproduct = '$a:$b:".":$d' - OR taxproduct = '$a:$b:".":' )"; - map { $_->taxproductnum } qsearch( { 'table' => 'part_pkg_taxproduct', - 'hashref' => { 'data_vendor'=>'cch' }, - 'extra_sql' => $extra_sql, - } ); - -} +=cut -sub part_pkg_taxrate { +sub tax_rates { my $self = shift; - my ($data_vendor, $geocode, $class) = @_; - - my $dbh = dbh; - my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '. - dbh->quote($data_vendor); - - # CCH oddness in m2m - $extra_sql .= ' AND ('. - join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) } - qw(10 5 2) - ). - ')'; - # much more CCH oddness in m2m -- this is kludgy - my @tpnums = $self->_expand_cch_taxproductnum($class); - if (scalar(@tpnums)) { - $extra_sql .= ' AND ('. - join(' OR ', map{ "taxproductnum = $_" } @tpnums ). - ')'; - } else { - $extra_sql .= ' AND ( 0 = 1 )'; + my ($vendor, $geocode, $class) = @_; + my @taxclassnums = map { $_->taxclassnum } + $self->part_pkg_taxoverride($class); + if (!@taxclassnums) { + my $part_pkg_taxproduct = $self->taxproduct($class); + @taxclassnums = map { $_->taxclassnum } + grep { $_->taxable eq 'Y' } # why do we need this? + $part_pkg_taxproduct->part_pkg_taxrate($geocode); } + return unless @taxclassnums; + + warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n" + if $DEBUG; + my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")"; + my @taxes = qsearch({ 'table' => 'tax_rate', + 'hashref' => { 'geocode' => $geocode, + 'data_vendor' => $vendor }, + 'extra_sql' => $extra_sql, + }); + warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n" + if $DEBUG; - my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )'; - my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc'; - my $select = 'DISTINCT ON(taxclassnum) *, taxproduct'; - - # should qsearch preface columns with the table to facilitate joins? - qsearch( { 'table' => 'part_pkg_taxrate', - 'select' => $select, - 'hashref' => { # 'data_vendor' => $data_vendor, - # 'taxproductnum' => $self->taxproductnum, - }, - 'addl_from' => $addl_from, - 'extra_sql' => $extra_sql, - 'order_by' => $order_by, - } ); + return @taxes; } =item part_pkg_discount diff --git a/FS/FS/part_pkg_taxproduct.pm b/FS/FS/part_pkg_taxproduct.pm index 56e63b668..ddea1da79 100644 --- a/FS/FS/part_pkg_taxproduct.pm +++ b/FS/FS/part_pkg_taxproduct.pm @@ -2,7 +2,7 @@ package FS::part_pkg_taxproduct; use strict; use vars qw( @ISA $delete_kludge ); -use FS::Record qw( qsearch ); +use FS::Record qw( qsearch dbh ); @ISA = qw(FS::Record); $delete_kludge = 0; @@ -123,12 +123,86 @@ sub check { $self->SUPER::check; } +=item part_pkg_taxrate GEOCODE + +Returns the L records (tax definitions) that can apply +to this tax product category in the location identified by GEOCODE. + +=cut + +# actually only returns one arbitrary record for each taxclassnum, making +# it useful only for retrieving the taxclassnums + +sub part_pkg_taxrate { + my $self = shift; + my $data_vendor = $self->data_vendor; # because duh + my $geocode = shift; + + my $dbh = dbh; + + # CCH oddness in m2m + my $extra_sql .= "AND part_pkg_taxrate.data_vendor = '$data_vendor' ". + "AND (". + join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) } + qw(10 5 2) + ). + ')'; + # much more CCH oddness in m2m -- this is kludgy + my $tpnums = join(',', + map { $_->taxproductnum } + $self->expand_cch_taxproduct + ); + $extra_sql .= "AND taxproductnum IN($tpnums)"; + + my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )'; + my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc'; + my $select = 'DISTINCT ON(taxclassnum) *, taxproduct'; + + # should qsearch preface columns with the table to facilitate joins? + qsearch( { 'table' => 'part_pkg_taxrate', + 'select' => $select, + 'hashref' => { 'taxable' => 'Y' }, + 'addl_from' => $addl_from, + 'extra_sql' => $extra_sql, + 'order_by' => $order_by, + } ); +} + +=item expand_cch_taxproduct + +Returns the full set of part_pkg_taxproduct records that are "implied" by +this one. + +=cut + +sub expand_cch_taxproduct { + my $self = shift; + my $class = shift; + + my ($a,$b,$c,$d) = split ':', $self->taxproduct; + $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d; + my $taxproducts = join(',', + "'${a}:${b}:${c}:${d}'", + "'${a}:${b}:${c}:'", + "'${a}:${b}::${d}'", + "'${a}:${b}::'" + ); + qsearch( { + 'table' => 'part_pkg_taxproduct', + 'hashref' => { 'data_vendor'=>'cch' }, + 'extra_sql' => "AND taxproduct IN($taxproducts)", + } ); +} + + =back =cut =head1 BUGS +Confusingly named. It has nothing to do with part_pkg. + =head1 SEE ALSO L, schema.html from the base documentation. diff --git a/FS/FS/pay_batch/RBC.pm b/FS/FS/pay_batch/RBC.pm index 6ee5771fe..753626620 100644 --- a/FS/FS/pay_batch/RBC.pm +++ b/FS/FS/pay_batch/RBC.pm @@ -93,6 +93,15 @@ $name = 'RBC'; row => sub { my ($cust_pay_batch, $pay_batch) = @_; my ($account, $aba) = split('@', $cust_pay_batch->payinfo); + my($bankno, $branch); + if ( $aba =~ /^0(\d{3})(\d{5})$/ ) { # standard format for Canadian bank ID + ($bankno, $branch) = ( $1, $2 ); + } elsif ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #how we store branches + ($branch, $bankno) = ( $1, $2 ); + } else { + die "invalid branch/routing number '$aba'\n"; + } + $i++; sprintf("%06u", $i). 'D'. @@ -101,8 +110,9 @@ $name = 'RBC'; ' '. sprintf("%-19s", $cust_pay_batch->paybatchnum). '00'. - sprintf("%09u", $aba). - sprintf("%-18s", $account). + sprintf("%04u", $bankno). + sprintf("%05u", $branch). + sprintf("%-18u", $account). ' '. sprintf("%010.0f",$cust_pay_batch->amount*100). ' '. @@ -129,7 +139,7 @@ $name = 'RBC'; 'Z'. 'TRL'. sprintf("%10s", $client_num). - ' ' x 20 . + '0' x 20 . sprintf("%06u", $batchcount). sprintf("%014.0f", $batchtotal*100). '00' . diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 3d37677fb..451600432 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -371,7 +371,7 @@ sub passtype_name { $tax_passtypes{$self->passtype}; } -=item taxline TAXABLES, [ OPTIONSHASH ] +=item taxline TAXABLES Returns a listref of a name and an amount of tax calculated for the list of packages/amounts referenced by TAXABLES. If an error occurs, a message @@ -381,13 +381,13 @@ is returned as a scalar. sub taxline { my $self = shift; + # this used to accept a hash of options but none of them did anything + # so it's been removed. my $taxables; - my %opt = (); if (ref($_[0]) eq 'ARRAY') { $taxables = shift; - %opt = @_; }else{ $taxables = [ @_ ]; #exemptions would be broken in this case diff --git a/FS/MANIFEST b/FS/MANIFEST index a0a71c971..129ee64df 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -758,3 +758,11 @@ FS/extension_device.pm t/extension_device.t FS/cust_main_credit_limit.pm t/cust_main_credit_limit.t +FS/cust_event_fee.pm +t/cust_event_fee.t +FS/part_fee.pm +t/part_fee.t +FS/cust_bill_pkg_fee.pm +t/cust_bill_pkg_fee.t +FS/part_fee_msgcat.pm +t/part_fee_msgcat.t diff --git a/FS/t/cust_bill_pkg_fee.t b/FS/t/cust_bill_pkg_fee.t new file mode 100644 index 000000000..c7cf0a0e5 --- /dev/null +++ b/FS/t/cust_bill_pkg_fee.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_bill_pkg_fee; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/cust_event_fee.t b/FS/t/cust_event_fee.t new file mode 100644 index 000000000..882b1df94 --- /dev/null +++ b/FS/t/cust_event_fee.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_event_fee; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/part_fee.t b/FS/t/part_fee.t new file mode 100644 index 000000000..b4192a4c9 --- /dev/null +++ b/FS/t/part_fee.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::part_fee; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/part_fee_msgcat.t b/FS/t/part_fee_msgcat.t new file mode 100644 index 000000000..f7e8ca8e7 --- /dev/null +++ b/FS/t/part_fee_msgcat.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::part_fee_msgcat; +$loaded=1; +print "ok 1\n"; diff --git a/bin/cdr-voipswitch.import b/bin/cdr-voipswitch.import index a8ab9d944..69d0636a7 100644 --- a/bin/cdr-voipswitch.import +++ b/bin/cdr-voipswitch.import @@ -122,7 +122,7 @@ my $row; while ( $row = $sth->fetchrow_hashref ) { my $ip = $row->{ip_number}; - if ( $ip =~ /^([\d\.]+)\/([\d\.]*)$/ ) { + if ( $ip =~ /^([\d\.]+)\/([\d\.]*)/ ) { $ip = $1; #$nat_ip = $2; } @@ -144,7 +144,7 @@ while ( $row = $sth->fetchrow_hashref ) { uniqueid => $row->{id_cc}, orig_call_id => $row->{clid}, billsec => $row->{effective_duration}, - lastdata => $row->{dtmf}, + #lastdata => $row->{dtmf}, disposition => $row->{call_data}, cdrbatchnum => $cdrbatchnum, diff --git a/bin/test_scrub_sql b/bin/test_scrub_sql index fb26fe940..fe66805d3 100755 --- a/bin/test_scrub_sql +++ b/bin/test_scrub_sql @@ -15,11 +15,11 @@ foreach my $table (qw( part_export_option - payment_gateway payment_gateway_option + payment_gateway agent_payment_gateway - queue queue_arg + queue cust_pay_batch )) { print "DELETE FROM $table;\n"; diff --git a/httemplate/browse/part_fee.html b/httemplate/browse/part_fee.html new file mode 100644 index 000000000..482c692d7 --- /dev/null +++ b/httemplate/browse/part_fee.html @@ -0,0 +1,71 @@ +<& elements/browse.html, + title => 'Fee definitions', + name_singular => 'fee definition', + query => $query, + count_query => $count_query, + header => [ '#', + 'Description', + 'Comment', + 'Class', + 'Amount', + 'Tax status', + ], + fields => [ 'feepart', + 'itemdesc', + 'comment', + 'classname', + $sub_amount, + $sub_tax, + ], + disableable => 1, + disabled_statuspos => 3, + agent_pos => 6, + agent_virt => 1, + agent_null_right=> 'Edit global fee definitions', + links => [ '', + $link, + $link, + ], + align => 'cllccc', + menubar => \@menubar, +&> +<%init> +my $curuser = $FS::CurrentUser::CurrentUser; +my $acl_edit = $curuser->access_right('Edit fee definitions'); +my $acl_edit_global = $curuser->access_right('Edit global fee definitions'); +die "access denied" + unless $acl_edit or $acl_edit_global; + +my $query = { + 'select' => 'part_fee.*,'. + '(select classname from pkg_class '. + 'where pkg_class.classnum = part_fee.classnum) AS classname', + 'table' => 'part_fee', +}; +my $count_query = "SELECT COUNT(*) FROM part_fee"; + +my $sub_amount = sub { + my $obj = shift; + my $string = $obj->explanation; + $string =~ s/\n/
/sg; + $string; +}; + +my $sub_tax = sub { + my $obj = shift; + if ( $obj->taxable ) { + return $obj->taxclass || 'taxable'; + } elsif ( $obj->taxproductnum ) { + return join('
', + split(/\s*:\s*/, $obj->part_pkg_taxproduct->description) + ); + } else { + return 'exempt'; + } +}; + +my $link = [ $p.'edit/part_fee.html?', 'feepart' ]; + +my @menubar = ( 'Add a new fee definition', + $p.'edit/part_fee.html' ); + diff --git a/httemplate/edit/credit-cust_bill_pkg.html b/httemplate/edit/credit-cust_bill_pkg.html index a5ecb69e3..40faddc46 100644 --- a/httemplate/edit/credit-cust_bill_pkg.html +++ b/httemplate/edit/credit-cust_bill_pkg.html @@ -269,7 +269,8 @@ my @cust_bill_pkg = qsearch({ 'select' => 'cust_bill_pkg.*', 'table' => 'cust_bill_pkg', 'addl_from' => 'LEFT JOIN cust_bill USING (invnum)', - 'extra_sql' => "WHERE custnum = $custnum AND pkgnum != 0", + 'extra_sql' => "WHERE custnum = $custnum ". + "AND (pkgnum != 0 or feepart IS NOT NULL)", 'order_by' => 'ORDER BY invnum ASC, billpkgnum ASC', }); diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html new file mode 100644 index 000000000..dada23360 --- /dev/null +++ b/httemplate/edit/part_fee.html @@ -0,0 +1,141 @@ +<& elements/edit.html, + 'name_singular' => 'fee definition', + 'table' => 'part_fee', + 'labels' => { + 'feepart' => 'Fee definition', + 'itemdesc' => 'Description', + 'comment' => 'Comment (customer-hidden)', + 'classnum' => 'Package class', + 'taxable' => 'This fee is taxable', + 'disabled' => 'Disable this fee', + 'taxclass' => 'Tax class name', + 'taxproductnum' => 'Tax product', + 'pay_weight' => 'Payment weight', + 'credit_weight' => 'Credit weight', + 'agentnum' => 'Agent', + 'amount' => 'Flat fee amount', + 'percent' => 'Percentage of invoice amount', + 'basis' => 'Based on', + 'setuprecur' => 'Report this fee as', + 'minimum' => 'Minimum fee', + 'maximum' => 'Maximum fee', + 'limit_credit' => 'Limit to customer credit balance', + %locale_labels + }, + 'fields' => \@fields, + 'edit_callback' => $edit_callback, + 'error_callback' => $error_callback, +&> +<%init> +my $curuser = $FS::CurrentUser::CurrentUser; +my $acl_edit = $curuser->access_right('Edit fee definitions'); +my $acl_edit_global = $curuser->access_right('Edit global fee definitions'); +die "access denied" + unless $acl_edit or $acl_edit_global; + +my $conf = FS::Conf->new; +my @tax_fields; +if ( $conf->exists('enable_taxproducts') ) { + @tax_fields = ( + { field => 'taxproductnum', type => 'select-taxproduct' } + ); +} else { + @tax_fields = ( + { field => 'taxable', type => 'checkbox', value => 'Y' }, + ); + push ( + { field => 'taxclass', type => 'select-taxclass' }, + ) if $conf->exists('enable_taxclasses'); +} + +my $default_locale = $conf->config('locale') || 'en_US'; +my @locales = grep {$_ ne $default_locale} $conf->config('available-locales'); +# duplicates edit/part_pkg.cgi, yuck +my $n = 0; +my (@locale_fields, %locale_labels); +foreach (@locales) { + push @locale_fields, + { field => 'feepartmsgnum'. $n, type => 'hidden' }, + { field => 'feepartmsgnum'. $n. '_locale', type => 'hidden' }, + { field => 'feepartmsgnum'. $n. '_itemdesc', type => 'text', size => 40 }, + ; + $locale_labels{ 'feepartmsgnum'.$n.'_itemdesc' } = + 'Description—' . FS::Locales->description($_); + $n++; +} + +my @fields = ( + + { field => 'itemdesc', type => 'text', size => 40, }, + @locale_fields, + + { field => 'comment', type => 'text', size => 40, }, + + { field => 'agentnum', + type => 'select-agent', + disable_empty => !$acl_edit_global, + empty_label => '(global)', + }, + + { field => 'classnum', + type => 'select-pkg_class', + }, + + { field => 'disabled', + type => 'checkbox', + value => 'Y', + }, + + { field => 'setuprecur', + type => 'select', + options => [ 'setup', 'recur' ], + labels => { 'setup' => 'a setup fee', + 'recur' => 'a recurring charge' }, + }, + + { type => 'justtitle', value => 'Fee calculation' }, + { field => 'amount', type => 'money', }, + { field => 'percent', type => 'percentage', }, + + { field => 'basis', + type => 'select', + options => [ 'charged', 'owed' ], + labels => { 'charged' => 'amount charged', + 'owed' => 'balance due', }, + }, + + { field => 'minimum', type => 'money', }, + { field => 'maximum', type => 'money', }, + { field => 'limit_credit', + type => 'checkbox', + value => 'Y' }, + + { type => 'justtitle', value => 'Taxation' }, + + @tax_fields, +); + +my $edit_callback = sub { + my ($cgi, $obj, $fields, $opt) = @_; + my %existing_locales; + if ( $obj->feepart ) { + %existing_locales = map { $_->locale => $_ } $obj->part_fee_msgcat; + } + my $n = 0; + foreach (@locales) { + $obj->set('feepartmsgnum'.$n.'_locale', $_); + # load the existing itemdescs + if ( my $msgcat = $existing_locales{$_} ) { + $obj->set('feepartmsgnum'.$n, $msgcat->feepartmsgnum); + $obj->set('feepartmsgnum'.$n.'_itemdesc', $msgcat->itemdesc); + } + # then override that with the CGI param if there is one + if ( my $itemdesc = $cgi->param('feepartmsgnum'.$n.'_itemdesc') ) { + $obj->set('feepartmsgnum'.$n.'_itemdesc', $itemdesc); + } + $n++; + } +}; + +my $error_callback = $edit_callback; + diff --git a/httemplate/edit/process/part_fee.html b/httemplate/edit/process/part_fee.html new file mode 100755 index 000000000..25656e9b0 --- /dev/null +++ b/httemplate/edit/process/part_fee.html @@ -0,0 +1,20 @@ +<& elements/process.html, + 'debug' => 1, + 'table' => 'part_fee', + 'agent_virt' => 1, + 'agent_null_right' => 'Edit global fee definitions', + 'viewall_dir' => 'browse', + 'process_o2m' => { + 'table' => 'part_fee_msgcat', + 'fields' => [ 'locale', 'itemdesc' ], + }, +&> +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; +my $acl_edit = $curuser->access_right('Edit fee definitions'); +my $acl_edit_global = $curuser->access_right('Edit global fee definitions'); +die "access denied" + unless $acl_edit or $acl_edit_global; + + diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index fb84e7501..cd4fb39ec 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -584,6 +584,10 @@ if ( $curuser->access_right('Configuration') ) { $config_pkg{'Package report classes'} = [ $fsurl.'browse/part_pkg_report_option.html', 'Package classes define optional groups of packages for reporting only.' ]; #eo package grouping sub-menu + if ( $curuser->access_right([ 'Edit fee definitions', + 'Edit global fee definitions' ]) ) { + $config_pkg{'Fees'} = [ $fsurl.'browse/part_fee.html', '' ]; + } $config_pkg{'Discounts'} = [ $fsurl.'browse/discount.html', '' ]; $config_pkg{'Discount classes'} = [ $fsurl.'browse/discount_class.html', '' ]; $config_pkg{'Cancel/Suspend Reasons'} = [ \%config_pkg_reason, '' ]; diff --git a/httemplate/misc/xmlhttp-calculate_taxes.html b/httemplate/misc/xmlhttp-calculate_taxes.html index ed7bd0173..2bb1f4cce 100644 --- a/httemplate/misc/xmlhttp-calculate_taxes.html +++ b/httemplate/misc/xmlhttp-calculate_taxes.html @@ -62,14 +62,7 @@ if ( $sub eq 'calculate_taxes' ) { my $taxlisthash = {}; foreach my $cust_bill_pkg (values %cust_bill_pkg) { - my $part_pkg = $cust_bill_pkg->part_pkg; - $cust_main->_handle_taxes( $part_pkg, - $taxlisthash, - $cust_bill_pkg, - $cust_bill_pkg->cust_pkg, - $cust_bill_pkg->cust_bill->_date, - $cust_bill_pkg->cust_pkg->pkgpart, - ); + $cust_main->_handle_taxes( $taxlisthash, $cust_bill_pkg ); } my $listref_or_error = $cust_main->calculate_taxes( [ values %cust_bill_pkg ], $taxlisthash, [ values %cust_bill_pkg ]->[0]->cust_bill->_date ); diff --git a/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html b/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html index c0db3e2c4..4558682bd 100644 --- a/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html +++ b/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html @@ -62,15 +62,7 @@ if ( $sub eq 'calculate_taxes' ) { push @cust_bill_pkg, $cust_bill_pkg; - my $part_pkg = $cust_bill_pkg->part_pkg; - $cust_main->_handle_taxes( $part_pkg, - $taxlisthash, - $cust_bill_pkg, - $cust_bill_pkg->cust_pkg, - $cust_bill_pkg->cust_bill->_date, - $cust_bill_pkg->cust_pkg->pkgpart, - ); - + $cust_main->_handle_taxes( $taxlisthash, $cust_bill_pkg ); } if ( @cust_bill_pkg ) { @@ -89,7 +81,10 @@ if ( $sub eq 'calculate_taxes' ) { foreach my $taxline ( @$listref_or_error ) { my $amount = $taxline->setup; my $desc = $taxline->desc; - foreach my $location ( @{$taxline->cust_bill_pkg_tax_location}, @{$taxline->cust_bill_pkg_tax_rate_location} ) { + foreach my $location ( + @{$taxline->get('cust_bill_pkg_tax_location')}, + @{$taxline->get('cust_bill_pkg_tax_rate_location')} ) + { my $taxlocnum = $location->locationnum || ''; my $taxratelocnum = $location->taxratelocationnum || ''; $location->cust_bill_pkg_desc($taxline->desc); #ugh @ that kludge diff --git a/httemplate/pref/pref-process.html b/httemplate/pref/pref-process.html index 7848b72cb..6d4f89a77 100644 --- a/httemplate/pref/pref-process.html +++ b/httemplate/pref/pref-process.html @@ -59,6 +59,7 @@ unless ( $error ) { # if ($access_user) { snom-ip snom-username snom-password vonage-fromnumber vonage-username vonage-password cust_pkg-display_times + hide_package_changes show_pkgnum show_confitem_counts export_getsettings show_db_profile save_db_profile save_tmp_typesetting height width availHeight availWidth colorDepth diff --git a/httemplate/pref/pref.html b/httemplate/pref/pref.html index ccfeecd77..eaa7d3281 100644 --- a/httemplate/pref/pref.html +++ b/httemplate/pref/pref.html @@ -78,6 +78,22 @@ Interface + + Hide package changes: + + <& /elements/select.html, + field => 'hide_package_changes', + options => [ '', 'location', 'all' ], + labels => { '' => 'never', + 'location' => 'location changes', + 'all' => 'all package changes', + }, + curr_value => ($cgi->param('hide_package_changes') + || $curuser->option('hide_package_changes')), + &> + + + % my $history_order = $curuser->option('history_order') || 'oldest'; Customer history sort order: diff --git a/httemplate/search/cust_bill_pkg.cgi b/httemplate/search/cust_bill_pkg.cgi index 6b7a5e6e2..440ab150c 100644 --- a/httemplate/search/cust_bill_pkg.cgi +++ b/httemplate/search/cust_bill_pkg.cgi @@ -137,9 +137,9 @@ Filtering parameters: - use_override: Apply "classnum" and "taxclass" filtering based on the override (bundle) pkgpart, rather than always using the true pkgpart. -- nottax: Limit to items that are not taxes (pkgnum > 0). +- nottax: Limit to items that are not taxes (pkgnum > 0 or feepart > 0). -- istax: Limit to items that are taxes (pkgnum == 0). +- istax: Limit to items that are taxes (pkgnum == 0 and feepart = null). - taxnum: Limit to items whose tax definition matches this taxnum. With "nottax" that means items that are subject to that tax; @@ -305,7 +305,8 @@ if ( $cgi->param('custnum') =~ /^(\d+)$/ ) { # we want the package and its definition if available my $join_pkg = ' LEFT JOIN cust_pkg USING (pkgnum) - LEFT JOIN part_pkg USING (pkgpart)'; + LEFT JOIN part_pkg USING (pkgpart) + LEFT JOIN part_fee USING (feepart)'; my $part_pkg = 'part_pkg'; # "Separate sub-packages from parents" @@ -319,12 +320,16 @@ if ( $use_override ) { $part_pkg = 'override'; } push @select, "$part_pkg.pkgpart", "$part_pkg.pkg"; -push @select, "$part_pkg.taxclass" if $conf->exists('enable_taxclasses'); +push @select, "COALESCE($part_pkg.taxclass, part_fee.taxclass) AS taxclass" + if $conf->exists('enable_taxclasses'); # the non-tax case if ( $cgi->param('nottax') ) { - push @where, 'cust_bill_pkg.pkgnum > 0'; + push @select, "part_fee.itemdesc"; + + push @where, + '(cust_bill_pkg.pkgnum > 0 OR cust_bill_pkg.feepart IS NOT NULL)'; my @tax_where; # will go into a subquery my @exempt_where; # will also go into a subquery @@ -335,7 +340,7 @@ if ( $cgi->param('nottax') ) { # N: classnum if ( grep { $_ eq 'classnum' } $cgi->param ) { my @classnums = grep /^\d*$/, $cgi->param('classnum'); - push @where, "COALESCE($part_pkg.classnum, 0) IN ( ". + push @where, "COALESCE(part_fee.classnum, $part_pkg.classnum, 0) IN ( ". join(',', @classnums ). ' )' if @classnums; @@ -360,7 +365,7 @@ if ( $cgi->param('nottax') ) { # effective taxclass, not the real one push @tax_where, 'cust_main_county.taxclass IS NULL' } elsif ( $cgi->param('taxclass') ) { - push @tax_where, "$part_pkg.taxclass IN (" . + push @tax_where, "COALESCE(part_fee.taxclass, $part_pkg.taxclass) IN (" . join(', ', map {dbh->quote($_)} $cgi->param('taxclass') ). ')'; } @@ -681,7 +686,7 @@ if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) { 'paid' => ($cgi->param('paid') ? 1 : 0), 'classnum' => scalar($cgi->param('classnum')) ); - $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_pkg.classnum, 0) )"; + $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_fee.classnum, part_pkg.classnum, 0) )"; my $extra_sql = $subsearch->{extra_sql}; $extra_sql =~ s/^WHERE//; diff --git a/httemplate/view/cust_main/packages.html b/httemplate/view/cust_main/packages.html index 566ab2943..746e0c7c5 100755 --- a/httemplate/view/cust_main/packages.html +++ b/httemplate/view/cust_main/packages.html @@ -22,8 +22,62 @@ table.usage { .row0 { background-color: #eeeeee; } .row1 { background-color: #ffffff; } - +table.hiddenrows { + width: 80%; + margin-left: 100px; + border: 1px solid #7E0079; + background-color: #cccccc; +} + +.hiddenrows td { + text-align: center; +} +.rolldown_button { + min-width: 80px; + margin-left: 100px; + min-height: 20px; + background-color: #efefef; + border: 1px solid #7e0079; + z-index: 1; + text-align: center; +} + +% # activate rolldown buttons for hidden package blocks + % unless ( $opt{no_links} ) { % my $s = 0; @@ -124,7 +178,7 @@ table.usage { % if ( $conf->exists('cust_pkg-group_by_location') ) { <& locations.html, 'cust_main' => $cust_main, - 'packages' => $packages, + 'packages' => \@packages, %opt, &> % } @@ -133,7 +187,7 @@ table.usage { <& /elements/table-grid.html &> <& packages/section.html, 'cust_main' => $cust_main, - 'packages' => $packages, + 'packages' => \@packages, %opt, &> @@ -141,15 +195,6 @@ table.usage { -% if ( $cgi->param('fragment') =~ /^cust_pkg(\d+)$/ ) { - -% } <%init> @@ -159,94 +204,113 @@ my $conf = new FS::Conf; my $curuser = $FS::CurrentUser::CurrentUser; -my( $packages, $num_old_packages ) = get_packages($cust_main, $conf); - my $countrydefault = scalar($conf->config('countrydefault')) || 'US'; -#subroutines - -sub get_packages { - my $cust_main = shift or return undef; - my $conf = shift; - - my $method; - if ( $cgi->param('showcancelledpackages') eq '0' #see if it was set by me - || ( $conf->exists('hidecancelledpackages') - && ! $cgi->param('showcancelledpackages') ) - ) - { - $method = 'ncancelled_pkgs'; - } else { - $method = 'all_pkgs'; - } - my $cust_pkg_fields = - join(', ', map { "cust_pkg.$_ AS $_" } fields('cust_pkg') ); +my $hide_changed = $curuser->option('hide_package_changes'); - my $part_pkg_fields = - join(', ', map { "part_pkg.$_ AS part_pkg_$_" } fields('part_pkg') ); +my $hide_cancelled = 0; +if ( $cgi->param('showcancelledpackages') eq '0' #see if it was set by me + || ( $conf->exists('hidecancelledpackages') + && ! $cgi->param('showcancelledpackages') ) + ) +{ + $hide_cancelled = 1; +} - my $group_by = - join(', ', map "cust_pkg.$_", fields('cust_pkg') ). ', '. - join(', ', map "part_pkg.$_", fields('part_pkg') ); +my $cust_pkg_fields = + join(', ', map { "cust_pkg.$_ AS $_" } fields('cust_pkg') ); - my $num_svcs = '( SELECT COUNT(*) FROM cust_svc '. - ' WHERE cust_svc.pkgnum = cust_pkg.pkgnum ) AS num_svcs'; +my $part_pkg_fields = + join(', ', map { "part_pkg.$_ AS part_pkg_$_" } fields('part_pkg') ); - my @packages = $cust_main->$method( { - 'select' => "$cust_pkg_fields, $part_pkg_fields, $num_svcs", - 'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )', - } ); - my $num_old_packages = scalar(@packages); +my $group_by = + join(', ', map "cust_pkg.$_", fields('cust_pkg') ). ', '. + join(', ', map "part_pkg.$_", fields('part_pkg') ); - my %change_to_from; # target pkgnum => current cust_pkg, for future changes +my $num_svcs = '( SELECT COUNT(*) FROM cust_svc '. + ' WHERE cust_svc.pkgnum = cust_pkg.pkgnum ) AS num_svcs'; - foreach my $cust_pkg ( @packages ) { - my %hash = $cust_pkg->hash; - my %part_pkg = map { /^part_pkg_(.+)$/ or die; ( $1 => $hash{$_} ); } - grep { /^part_pkg_/ } keys %hash; - $cust_pkg->{'_pkgpart'} = new FS::part_pkg \%part_pkg; - if ( $cust_pkg->change_to_pkgnum ) { - $change_to_from{$cust_pkg->change_to_pkgnum} = $cust_pkg; - } +# don't exclude cancelled packages at this stage +my @packages = $cust_main->all_pkgs( { + 'select' => "$cust_pkg_fields, $part_pkg_fields, $num_svcs", + 'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )', +} ); + +my %change_to_from; # target pkgnum => current cust_pkg, for future changes +my %changed_from; # old pkgnum => new cust_pkg, for past changes + +foreach my $cust_pkg ( @packages ) { + my %hash = $cust_pkg->hash; + my %part_pkg = map { /^part_pkg_(.+)$/ or die; ( $1 => $hash{$_} ); } + grep { /^part_pkg_/ } keys %hash; + $cust_pkg->{'_pkgpart'} = new FS::part_pkg \%part_pkg; + if ( $cust_pkg->change_to_pkgnum ) { + $change_to_from{$cust_pkg->change_to_pkgnum} = $cust_pkg; } + if ( $cust_pkg->change_pkgnum ) { + $changed_from{$cust_pkg->change_pkgnum} = $cust_pkg; + } +} - if ( keys %change_to_from ) { - my @not_future_packages; - foreach my $cust_pkg (@packages) { - if ( exists( $change_to_from{$cust_pkg->pkgnum} ) ) { - my $change_from = $change_to_from{ $cust_pkg->pkgnum }; - $cust_pkg->set('change_from_pkg', $change_from); - $change_from->set('change_to_pkg', $cust_pkg); - } else { - push @not_future_packages, $cust_pkg; +# filter out hidden package changes +if ( keys %change_to_from or keys %changed_from ) { + my @displayable_packages; + foreach my $cust_pkg (@packages) { + if ( exists( $change_to_from{$cust_pkg->pkgnum} ) ) { + # $cust_pkg is an ordered, not-yet-active package change target + my $change_from = $change_to_from{ $cust_pkg->pkgnum }; + $cust_pkg->set('change_from_pkg', $change_from); + $change_from->set('change_to_pkg', $cust_pkg); + } elsif ( exists( $changed_from{$cust_pkg->pkgnum} ) ) { + # $cust_pkg is a canceled package changed into another packge + my $changed_to = $changed_from{$cust_pkg->pkgnum}; + if ( ( $hide_changed eq 'all' ) or + ( $hide_changed eq 'location' + and $changed_to->pkgpart == $cust_pkg->pkgpart + and $changed_to->refnum == $cust_pkg->refnum + and $changed_to->quantity == $cust_pkg->quantity ) + ) { + # then we're hiding it + $cust_pkg->set('changed_to_pkg', $changed_to); + $changed_to->set('changed_from_pkg', $cust_pkg); + } else { # show it anyway + push @displayable_packages, $cust_pkg; } + } else { + push @displayable_packages, $cust_pkg; } - @packages = @not_future_packages; } + @packages = @displayable_packages; +} - unless ( $cgi->param('showoldpackages') ) { - my $years = $conf->config('cust_main-packages-years') || 2; - my $then = time - $years * 31556926; #60*60*24*365.2422 is close enough - - my %hide = ( 'cancelled' => 'cancel', - 'one-time charge' => 'setup', - ); - - @packages = - grep { !exists($hide{$_->status}) or $_->get($hide{$_->status}) > $then - or $_->num_svcs #don't hide packages w/services - } - @packages; - } +# filter all cancelled packages if the user wants +if ( $hide_cancelled ) { + @packages = grep { !$_->get('cancel') } @packages; +} + +# filter out 'old' packages +my $num_old_packages = scalar(@packages); - $num_old_packages -= scalar(@packages); - - # don't include supplemental packages in this list; they'll be found from - # their main packages - # (as will change-target packages) - @packages = grep !$_->main_pkgnum, @packages; +unless ( $cgi->param('showoldpackages') ) { + my $years = $conf->config('cust_main-packages-years') || 2; + my $then = time - $years * 31556926; #60*60*24*365.2422 is close enough - ( \@packages, $num_old_packages ); + my %hide = ( 'cancelled' => 'cancel', + 'one-time charge' => 'setup', + ); + + @packages = + grep { !exists($hide{$_->status}) or $_->get($hide{$_->status}) > $then + or $_->num_svcs #don't hide packages w/services + } + @packages; } +$num_old_packages -= scalar(@packages); + +# don't include supplemental packages in this list; they'll be found from +# their main packages +# (as will change-target packages) +@packages = grep !$_->main_pkgnum, @packages; + diff --git a/httemplate/view/cust_main/packages/hidden.html b/httemplate/view/cust_main/packages/hidden.html new file mode 100644 index 000000000..e3bd0fabf --- /dev/null +++ b/httemplate/view/cust_main/packages/hidden.html @@ -0,0 +1,55 @@ +% if (!$iopt{noframe}) { +% # then start the block here, and assign a suitable ID (cust_pkgX_block) + +% } + + + + +% if ( $cust_pkg->get('changed_from_pkg') ) { +<& hidden.html, $cust_pkg->get('changed_from_pkg'), + %iopt, + 'next_pkg' => $cust_pkg, + 'noframe' => 1 +&> +% } +% if ( !$iopt{noframe} ) { + +% } +<%init> +my $cust_pkg = shift; +my $part_pkg = $cust_pkg->part_pkg; +my %iopt = @_; +my $next = delete($iopt{'next_pkg'}); +my $curuser = $FS::CurrentUser::CurrentUser; +my $pkgnum = $curuser->option('show_pkgnum') ? $cust_pkg->pkgnum.': ' : ''; + +my $id = "cust_pkg".$cust_pkg->pkgnum; + +my $pkgpart_change = ($next->pkgpart != $cust_pkg->pkgpart); +my $location_change = ($next->locationnum != $cust_pkg->locationnum); +my $both_change = $pkgpart_change && $location_change; + + diff --git a/httemplate/view/cust_main/packages/section.html b/httemplate/view/cust_main/packages/section.html index 152ccaa5d..730bb2cf0 100755 --- a/httemplate/view/cust_main/packages/section.html +++ b/httemplate/view/cust_main/packages/section.html @@ -35,6 +35,25 @@ <& services.html, %iopt &> +% # insert hidden predecessors to this package, if any +% # and a rolldown button to show them +% # (we'll make it do something later) +% if ( $cust_pkg->get('changed_from_pkg') ) { + + + +% # the hidden block here has ID="cust_pkgX" where X is the first pkgnum +% # it contains. + <& hidden.html, $cust_pkg->get('changed_from_pkg'), + %iopt, + 'next_pkg' => $cust_pkg, + &> + + +% } % $row++; % # show the change target, if there is one % if ( $cust_pkg->change_to_pkg ) {