From: Ivan Kohler Date: Fri, 3 Apr 2015 18:24:16 +0000 (-0700) Subject: Merge branch '20150325-cust_main-CurrentUser' of https://github.com/fozzmoo/Freeside... X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=0fda4498e5b48587090b03d40ea97fec1e024385;hp=5becafa6dfc3ee7c0f07543904f0f3e25aa64949 Merge branch '20150325-cust_main-CurrentUser' of https://github.com/fozzmoo/Freeside into fozzmoo-20150325-cust_main-CurrentUser --- diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 2671afb70..471a09331 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -2432,26 +2432,26 @@ sub change_pkg { return { error=>"Can't change a suspended package", pkgnum=>$cust_pkg->pkgnum} if $cust_pkg->status eq 'suspended'; - my @newpkg; - my $error = FS::cust_pkg::order( $custnum, - [$p->{pkgpart}], - [$p->{pkgnum}], - \@newpkg, - ); + my $err_or_cust_pkg = $cust_pkg->change( 'pkgpart' => $p->{'pkgpart'}, + 'quantity' => $p->{'quantity'} || 1, + ); + + return { error=>$err_or_cust_pkg, pkgnum=>$cust_pkg->pkgnum } + unless ref($err_or_cust_pkg); if ( $conf->exists('signup_server-realtime') ) { my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_credit'=>1 ); if ($bill_error) { - $newpkg[0]->suspend; + $err_or_cust_pkg->suspend; return $bill_error; } else { - $newpkg[0]->reexport; + $err_or_cust_pkg->reexport; } } else { - $newpkg[0]->reexport; + $err_or_cust_pkg->reexport; } return { error => '', pkgnum => $cust_pkg->pkgnum }; diff --git a/FS/FS/FeeOrigin_Mixin.pm b/FS/FS/FeeOrigin_Mixin.pm new file mode 100644 index 000000000..8bd9acd2c --- /dev/null +++ b/FS/FS/FeeOrigin_Mixin.pm @@ -0,0 +1,129 @@ +package FS::FeeOrigin_Mixin; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); +use FS::part_fee; +use FS::cust_bill_pkg; + +# is there a nicer idiom for this? +our @subclasses = qw( FS::cust_event_fee FS::cust_pkg_reason_fee ); +use FS::cust_event_fee; +use FS::cust_pkg_reason_fee; + +=head1 NAME + +FS::FeeOrigin_Mixin - Common interface for fee origin 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::FeeOrigin_Mixin object associates the timestamped event that triggered +a fee (which may be a billing event, or something else like a package +suspension) to the resulting invoice line item (L object). +The following fields are required: + +=over 4 + +=item billpkgnum - key of the cust_bill_pkg record representing the fee +on an invoice. This is 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). + +=item nextbill - 'Y' if the fee should be charged on the customer's next bill, +rather than causing a bill to be produced immediately. + +=back + +=head1 CLASS METHODS + +=over 4 + +=item by_cust CUSTNUM[, PARAMS] + +Finds all cust_event_fee records belonging to the customer CUSTNUM. + +PARAMS can be additional params to pass to qsearch; this really only works +for 'hashref' and 'order_by'. + +=cut + +# invoke for all subclasses, and return the results as a flat list + +sub by_cust { + my $class = shift; + my @args = @_; + return map { $_->_by_cust(@args) } @subclasses; +} + +=back + +=head1 INTERFACE + +=over 4 + +=item _by_cust CUSTNUM[, PARAMS] + +The L search method. Each subclass must implement this. + +=item cust_bill + +If the fee origin generates a fee based on past invoices (for example, an +invoice event that charges late fees), this method should return the +L object that will be the basis for the fee. If this returns +nothing, then then fee will be based on the rest of the invoice where it +appears. + +=item cust_pkg + +If the fee origin generates a fee limited in scope to one package (for +example, a package reconnection fee event), this method should return the +L object the fee applies to. If it's a percentage fee, this +determines which charges it's a percentage of; otherwise it just affects the +fee description appearing on the invoice. + +Currently not tested in combination with L; be careful. + +=cut + +# stubs + +sub _by_cust { my $class = shift; die "'$class' must provide _by_cust method" } + +sub cust_bill { '' } + +sub cust_pkg { '' } + +# still necessary in 4.x; can't FK the billpkgnum because of voids +sub cust_bill_pkg { + my $self = shift; + $self->billpkgnum ? FS::cust_bill_pkg->by_key($self->billpkgnum) : ''; +} + +=head1 BUGS + +=head1 SEE ALSO + +L, L, L, +L + +=cut + +1; + diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 2cabf851d..8f7f73916 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -400,6 +400,7 @@ if ( -e $addl_handler_use_file ) { use FS::cust_contact; use FS::legacy_cust_history; use FS::quotation_pkg_tax; + use FS::cust_pkg_reason_fee; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 3cdad433a..4bc3598fe 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1151,6 +1151,7 @@ sub tables_hashref { 'amount', @money_type, '', '', 'currency', 'char', 'NULL', 3, '', '', 'taxable_billpkgnum', 'int', 'NULL', '', '', '', + 'taxclass', 'varchar', 'NULL', 10, '', '', ], 'primary_key' => 'billpkgtaxratelocationnum', 'unique' => [], @@ -2616,6 +2617,7 @@ sub tables_hashref { 'download', @date_type, '', '', 'upload', @date_type, '', '', 'title', 'varchar', 'NULL',255, '', '', + 'processor_id', 'varchar', 'NULL',255, '', '', ], 'primary_key' => 'batchnum', 'unique' => [], @@ -2851,6 +2853,29 @@ sub tables_hashref { ], }, + 'cust_pkg_reason_fee' => { + 'columns' => [ + 'pkgreasonfeenum', 'serial', '', '', '', '', + 'pkgreasonnum', 'int', '', '', '', '', + 'billpkgnum', 'int', 'NULL', '', '', '', + 'feepart', 'int', '', '', '', '', + 'nextbill', 'char', 'NULL', 1, '', '', + ], + 'primary_key' => 'pkgreasonfeenum', + 'unique' => [ [ 'billpkgnum' ], [ 'pkgreasonnum' ] ], # one-to-one link + 'index' => [ [ 'feepart' ] ], + 'foreign_keys' => [ + { columns => [ 'pkgreasonnum' ], + table => 'cust_pkg_reason', + references => [ 'num' ], + }, + { columns => [ 'feepart' ], + table => 'part_fee', + }, + # can't link billpkgnum, because of voids + ], + }, + 'cust_pkg_discount' => { 'columns' => [ 'pkgdiscountnum', 'serial', '', '', '', '', @@ -4510,6 +4535,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -4525,16 +4551,13 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], 'foreign_keys' => [ { columns => [ 'billpkgnum' ], table => 'cust_bill_pkg', }, - { columns => [ 'taxnum' ], - table => 'cust_main_county', - }, { columns => [ 'creditbillpkgnum' ], table => 'cust_credit_bill_pkg', }, @@ -4547,6 +4570,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -4562,7 +4586,7 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], 'foreign_keys' => [ @@ -4677,7 +4701,6 @@ sub tables_hashref { 'suid', 'int', 'NULL', '', '', '', 'shared_svcnum', 'int', 'NULL', '', '', '', 'serviceid', 'varchar', 'NULL', 64, '', '',#srvexport/reportfields - 'cacti_leaf_id', 'int', 'NULL', '', '', '', ], 'primary_key' => 'svcnum', 'unique' => [ [ 'ip_addr' ], [ 'mac_addr' ] ], @@ -5985,6 +6008,9 @@ sub tables_hashref { 'unsuspend_pkgpart', 'int', 'NULL', '', '', '', 'unsuspend_hold','char', 'NULL', 1, '', '', 'unused_credit', 'char', 'NULL', 1, '', '', + 'feepart', 'int', 'NULL', '', '', '', + 'fee_on_unsuspend','char', 'NULL', 1, '', '', + 'fee_hold', 'char', 'NULL', 1, '', '', ], 'primary_key' => 'reasonnum', 'unique' => [], diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm index a146c54d1..54e305f23 100644 --- a/FS/FS/TaxEngine.pm +++ b/FS/FS/TaxEngine.pm @@ -14,22 +14,22 @@ FS::TaxEngine - Base class for tax calculation engines. =head1 USAGE 1. At the start of creating an invoice, create an FS::TaxEngine object. -2. Each time a sale item is added to the invoice, call C on the +2. Each time a sale item is added to the invoice, call L on the TaxEngine. - -- If the TaxEngine is "batch" style (Billsoft): 3. Set the "pending" flag on the invoice. 4. Insert the invoice and its line items. + +- If the TaxEngine is "batch" style (Billsoft): 5. After creating all invoices for the day, call FS::TaxEngine::process_tax_batch. This will create the tax items for all of the pending invoices, clear the "pending" flag, and call - C on each of the billed customers. + L on each of the billed customers. - If not (the internal tax system, CCH): -3. After adding all sale items, call C on the TaxEngine to +5. After adding all sale items, call L on the TaxEngine to produce a list of tax line items. -4. Append the tax line items to the invoice. -5. Insert the invoice. +6. Append the tax line items to the invoice. +7. Update the invoice with the new charged amount and clear the pending flag. =head1 CLASS METHODS @@ -48,15 +48,15 @@ indicate that the package is being billed on cancellation. sub new { my $class = shift; my %opt = @_; + my $conf = FS::Conf->new; if ($class eq 'FS::TaxEngine') { - my $conf = FS::Conf->new; my $subclass = $conf->config('enable_taxproducts') || 'internal'; $class .= "::$subclass"; local $@; eval "use $class"; die "couldn't load $class: $@\n" if $@; } - my $self = { items => [], taxes => {}, %opt }; + my $self = { items => [], taxes => {}, conf => $conf, %opt }; bless $self, $class; } @@ -84,33 +84,36 @@ Returns a hashref of metadata about this tax method, including: Adds the CUST_BILL_PKG object as a taxable sale on this invoice. -=item calculate_taxes CUST_BILL +=item calculate_taxes INVOICE Calculates the taxes on the taxable sales and returns a list of -L objects to add to the invoice. There is a base -implementation of this, which calls the C method to calculate -each individual tax. +L objects to add to the invoice. The base implementation +is to call L to produce a list of "raw" tax line items, +then L to combine those with the same itemdesc. =cut sub calculate_taxes { my $self = shift; - my $conf = FS::Conf->new; - my $cust_bill = shift; - my @tax_line_items; - # keys are tax names (as printed on invoices / itemdesc ) - # values are arrayrefs of taxlines - my %taxname; + my @raw_taxlines = $self->make_taxlines($cust_bill); - # keys are taxnums - # values are (cumulative) amounts - my %tax_amount; + my @real_taxlines = $self->consolidate_taxlines(@raw_taxlines); - # keys are taxnums - # values are arrayrefs of cust_tax_exempt_pkg objects - my %tax_exemption; + if ( $cust_bill and $cust_bill->get('invnum') ) { + $_->set('invnum', $cust_bill->get('invnum')) foreach @real_taxlines; + } + return \@real_taxlines; +} + +sub make_taxlines { + my $self = shift; + my $conf = $self->{conf}; + + my $cust_bill = shift; + + my @taxlines; # For each distinct tax rate definition, calculate the tax and exemptions. foreach my $taxnum ( keys %{ $self->{taxes} } ) { @@ -127,25 +130,57 @@ sub calculate_taxes { # with their link records die $taxline unless ref($taxline); - push @{ $taxname{ $taxline->itemdesc } }, $taxline; + push @taxlines, $taxline; } #foreach $taxnum + return @taxlines; +} + +sub consolidate_taxlines { + + my $self = shift; + my $conf = $self->{conf}; + + my @raw_taxlines = @_; + my @tax_line_items; + + # keys are tax names (as printed on invoices / itemdesc ) + # values are arrayrefs of taxlines + my %taxname; + # collate these by itemdesc + foreach my $taxline (@raw_taxlines) { + my $taxname = $taxline->itemdesc; + $taxname{$taxname} ||= []; + push @{ $taxname{$taxname} }, $taxline; + } + + # keys are taxnums + # values are (cumulative) amounts + my %tax_amount; + my $link_table = $self->info->{link_table}; + + # Preconstruct cust_bill_pkg objects that will become the "final" + # taxlines for each name, so that we can reference them. + # (keys are taxnames) + my %real_taxline_named = map { + $_ => FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'recur' => 0, + 'sdate' => '', + 'edate' => '', + 'itemdesc' => $_ + }) + } keys %taxname; + # For each distinct tax name (the values set as $taxline->itemdesc), # create a consolidated tax item with the total amount and all the links # of all tax items that share that name. foreach my $taxname ( keys %taxname ) { my @tax_links; - my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({ - 'invnum' => $cust_bill->invnum, - 'pkgnum' => 0, - 'recur' => 0, - 'sdate' => '', - 'edate' => '', - 'itemdesc' => $taxname, - $link_table => \@tax_links, - }); + my $tax_cust_bill_pkg = $real_taxline_named{$taxname}; + $tax_cust_bill_pkg->set( $link_table => \@tax_links ); my $tax_total = 0; warn "adding $taxname\n" if $DEBUG > 1; @@ -156,6 +191,16 @@ sub calculate_taxes { $tax_total += $taxitem->setup; foreach my $link ( @{ $taxitem->get($link_table) } ) { $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); + + # if the link represents tax on tax, also fix its taxable pointer + # to point to the "final" taxline + my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); + if (my $other_taxname = $taxable_cust_bill_pkg->itemdesc) { + $link->set('taxable_cust_bill_pkg', + $real_taxline_named{$other_taxname} + ); + } + push @tax_links, $link; } } # foreach $taxitem @@ -185,7 +230,7 @@ sub calculate_taxes { push @tax_line_items, $tax_cust_bill_pkg; } - \@tax_line_items; + @tax_line_items; } =head1 CLASS METHODS diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm index 6bad69e0d..4e6dbaf7e 100644 --- a/FS/FS/TaxEngine/cch.pm +++ b/FS/FS/TaxEngine/cch.pm @@ -8,7 +8,7 @@ use FS::Conf; =head1 SUMMARY -FS::TaxEngine::cch CCH published tax tables. Uses multiple tables: +FS::TaxEngine::cch - CCH published tax tables. Uses multiple tables: - tax_rate: definition of specific taxes, based on tax class and geocode. - cust_tax_location: definition of geocodes, using zip+4 codes. - tax_class: definition of tax classes. @@ -27,91 +27,74 @@ $DEBUG = 0; my %part_pkg_cache; -sub add_sale { - my ($self, $cust_bill_pkg, %options) = @_; +=item add_sale LINEITEM - my $part_item = $options{part_item} || $cust_bill_pkg->part_X; - my $location = $options{location} || $cust_bill_pkg->tax_location; +Takes LINEITEM (a L object) and adds it to three internal +data structures: - push @{ $self->{items} }, $cust_bill_pkg; +- C, an arrayref of all items on this invoice. +- C, a hashref of taxnum => arrayref containing the items that are + taxable under that tax definition. +- C, a hashref of taxnum => arrayref containing the tax class + names parallel to the C array for the same tax. - my $conf = FS::Conf->new; +The item will appear on C once for each tax class (setup, recur, +or a usage class number) that's taxable under that class and appears on +the item. - my @classes; - push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; - # debatable - push @classes, 'setup' if ($cust_bill_pkg->setup && !$self->{cancel}); - push @classes, 'recur' if ($cust_bill_pkg->recur && !$self->{cancel}); +C will also determine any exemptions that apply to the item +and attach them to LINEITEM. - my %taxes_for_class; - - my $exempt = $conf->exists('cust_class-tax_exempt') - ? ( $self->cust_class ? $self->cust_class->tax : '' ) - : $self->{cust_main}->tax; - # standardize this just to be sure - $exempt = ($exempt eq 'Y') ? 'Y' : ''; - - if ( !$exempt ) { +=cut - foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes( $part_item, $class, $location ); - return $err_or_ref unless ref($err_or_ref); - $taxes_for_class{$class} = $err_or_ref; - } - unless (exists $taxes_for_class{''}) { - my $err_or_ref = $self->_gather_taxes( $part_item, '', $location ); - return $err_or_ref unless ref($err_or_ref); - $taxes_for_class{''} = $err_or_ref; - } +sub add_sale { + my ($self, $cust_bill_pkg) = @_; - } + my $part_item = $cust_bill_pkg->part_X; + my $location = $cust_bill_pkg->tax_location; + my $custnum = $self->{cust_main}->custnum; - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr - foreach my $key (keys %tax_cust_bill_pkg) { - # $key is "setup", "recur", or a usage class name. ('' is a usage class.) - # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of - # the line item. - # $taxes_for_class{$key} is an arrayref of tax_rate objects that - # apply to $key-class charges. - my @taxes = @{ $taxes_for_class{$key} || [] }; - my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + push @{ $self->{items} }, $cust_bill_pkg; - my %localtaxlisthash = (); - foreach my $tax ( @taxes ) { + my $conf = FS::Conf->new; - my $taxnum = $tax->taxnum; - $self->{taxes}{$taxnum} ||= [ $tax ]; - push @{ $self->{taxes}{$taxnum} }, $tax_cust_bill_pkg; + my @classes; + my $usage = $cust_bill_pkg->usage || 0; + push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + if (!$self->{cancel}) { + push @classes, 'setup' if $cust_bill_pkg->setup > 0; + push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) > 0; + } - $localtaxlisthash{ $taxnum } ||= [ $tax ]; - push @{ $localtaxlisthash{$taxnum} }, $tax_cust_bill_pkg; + # About $self->{cancel}: This protects against charging per-line or + # per-customer or other flat-rate surcharges on a package that's being + # billed on cancellation (which is an out-of-cycle bill and should only + # have usage charges). See RT#29443. - } + # only calculate exemptions once for each tax rate, even if it's used for + # multiple classes. + my %tax_seen; - warn "finding taxed taxes...\n" if $DEBUG > 2; - foreach my $taxnum ( keys %localtaxlisthash ) { - my $tax_object = shift @{ $localtaxlisthash{$taxnum} }; + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); + return $err_or_ref unless ref($err_or_ref); + my @taxes = @$err_or_ref; - foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { - my $totnum = $tot->taxnum; + next if !@taxes; - # I'm not sure why, but for some reason we only add ToT if that - # tax_rate already applies to a non-tax item on the same invoice. - next unless exists( $localtaxlisthash{ $totnum } ); - warn "adding #$totnum to taxed taxes\n" if $DEBUG > 2; - # calculate the tax amount that the tax_on_tax will apply to - my $taxline = - $self->taxline( 'tax' => $tax_object, - 'sales' => $localtaxlisthash{$taxnum} - ); - return $taxline unless ref $taxline; - # and append it to the list of taxable items - $self->{taxes}->{$totnum} ||= [ $tot ]; - push @{ $self->{taxes}->{$totnum} }, $taxline->setup; - - } # foreach $tot (tax-on-tax) - } # foreach $tax - } # foreach $key (i.e. usage class) + foreach my $tax (@taxes) { + my $taxnum = $tax->taxnum; + $self->{taxes}{$taxnum} ||= []; + $self->{taxclass}{$taxnum} ||= []; + push @{ $self->{taxes}{$taxnum} }, $cust_bill_pkg; + push @{ $self->{taxclass}{$taxnum} }, $class; + + if ( !$tax_seen{$taxnum} ) { + $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum ); + $tax_seen{$taxnum}++; + } + } #foreach $tax + } #foreach $class } sub _gather_taxes { # interface for this sucks @@ -129,44 +112,95 @@ sub _gather_taxes { # interface for this sucks if $DEBUG; \@taxes; - } -sub taxline { - # FS::tax_rate::taxline() ridiculously returns a description and amount - # instead of a real line item. Fix that here. - # - # XXX eventually move the code from tax_rate to here - # but that's not necessary yet - my ($self, %opt) = @_; - my $tax_object = $opt{tax}; - my $taxables = $opt{sales}; - my $hashref = $tax_object->taxline_cch($taxables); - return $hashref unless ref $hashref; # it's an error message - - my $tax_amount = sprintf('%.2f', $hashref->{amount}); - my $tax_item = FS::cust_bill_pkg->new({ - 'itemdesc' => $hashref->{name}, - 'pkgnum' => 0, - 'recur' => 0, - 'sdate' => '', - 'edate' => '', - 'setup' => $tax_amount, - }); - my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ - 'taxnum' => $tax_object->taxnum, - 'taxtype' => ref($tax_object), #redundant - 'amount' => $tax_amount, - 'locationtaxid' => $tax_object->location, - 'taxratelocationnum' => - $tax_object->tax_rate_location->taxratelocationnum, - 'tax_cust_bill_pkg' => $tax_item, - # XXX still need to get taxable_cust_bill_pkg in here - # but that requires messing around in the taxline code - }); - $tax_item->set('cust_bill_pkg_tax_rate_location', [ $tax_link ]); - - return $tax_item; +# differs from stock make_taxlines because we need another pass to do +# tax on tax +sub make_taxlines { + my $self = shift; + my $cust_bill = shift; + + my @raw_taxlines; + my %taxable_location; # taxable billpkgnum => cust_location + my %item_has_tax; # taxable billpkgnum => taxnum + foreach my $taxnum ( keys %{ $self->{taxes} } ) { + my $tax_rate = FS::tax_rate->by_key($taxnum); + my $taxables = $self->{taxes}{$taxnum}; + my $charge_classes = $self->{taxclass}{$taxnum}; + foreach (@$taxables) { + $taxable_location{ $_->billpkgnum } ||= $_->tax_location; + } + + my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes ); + + next if !@taxlines; + if (!ref $taxlines[0]) { + # it's an error string + warn "error evaluating tax#$taxnum\n"; + return $taxlines[0]; + } + + my $billpkgnum = -1; # the current one + my $fragments; # $item_has_tax{$billpkgnum}{taxnum} + + foreach my $taxline (@taxlines) { + next if $taxline->setup == 0; + + my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0]; + # store this tax fragment, indexed by taxable item, then by taxnum + if ( $billpkgnum != $link->taxable_billpkgnum ) { + $billpkgnum = $link->taxable_billpkgnum; + $item_has_tax{$billpkgnum} ||= {}; + $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= []; + } + + $taxline->set('invnum', $cust_bill->invnum); + push @$fragments, $taxline; # so we can ToT it + push @raw_taxlines, $taxline; # so we actually bill it + } + } # foreach $taxnum + + # all first-tier taxes are calculated. now for tax on tax + # (has to be done on a per-taxable-item basis) + foreach my $billpkgnum (keys %item_has_tax) { + # taxes that apply to this item + my $this_has_tax = $item_has_tax{$billpkgnum}; + my $location = $taxable_location{$billpkgnum}; + foreach my $taxnum (keys %$this_has_tax) { + my $tax_rate = FS::tax_rate->by_key($taxnum); + # find all taxes that apply to it in this location + my @tot = $tax_rate->tax_on_tax( $location ); + next if !@tot; + + warn "found possible taxed taxnum $taxnum\n" + if $DEBUG > 2; + # Calculate ToT separately for each taxable item, and only if _that + # item_ is already taxed under the ToT. This is counterintuitive. + # See RT#5243. + foreach my $tot (@tot) { + my $totnum = $tot->taxnum; + warn "checking taxnum ".$tot->taxnum. + " which we call ". $tot->taxname ."\n" + if $DEBUG > 2; + if ( exists $this_has_tax->{ $totnum } ) { + warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n" + if $DEBUG; + my @taxlines = $tot->taxline_cch( + $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref) + ); + next if (!@taxlines); # it didn't apply after all + if (!ref($taxlines[0])) { + warn "error evaluating TOT ($totnum on $taxnum)\n"; + return $taxlines[0]; + } + # add these to the taxline queue + push @raw_taxlines, @taxlines; + } # if $this_has_tax->{$totnum} + } # foreach my $tot (tax-on-tax rate definition) + } # foreach $taxnum (first-tier rate definition) + } # foreach $taxable_item + + return @raw_taxlines; } sub cust_tax_locations { diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm index 60f7aad27..99535ad38 100644 --- a/FS/FS/TaxEngine/internal.pm +++ b/FS/FS/TaxEngine/internal.pm @@ -15,18 +15,17 @@ my %part_pkg_cache; sub add_sale { my ($self, $cust_bill_pkg) = @_; - my $cust_pkg = $cust_bill_pkg->cust_pkg; - my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; - my $part_pkg = $part_pkg_cache{$pkgpart} ||= FS::part_pkg->by_key($pkgpart) - or die "pkgpart $pkgpart not found"; - push @{ $self->{items} }, $cust_bill_pkg; - my $location = $cust_pkg->tax_location; # cacheable? + my $part_item = $cust_bill_pkg->part_X; + my $location = $cust_bill_pkg->tax_location; + my $custnum = $self->{cust_main}->custnum; + + push @{ $self->{items} }, $cust_bill_pkg; my @loc_keys = qw( district city county state country ); my %taxhash = map { $_ => $location->get($_) } @loc_keys; - $taxhash{'taxclass'} = $part_pkg->taxclass; + $taxhash{'taxclass'} = $part_item->taxclass; my @taxes = (); # entries are cust_main_county objects my %taxhash_elim = %taxhash; @@ -46,9 +45,10 @@ sub add_sale { $taxhash_elim{ shift(@elim) } = ''; } while ( !scalar(@taxes) && scalar(@elim) ); - foreach (@taxes) { - my $taxnum = $_->taxnum; - $self->{taxes}->{$taxnum} ||= [ $_ ]; + foreach my $tax (@taxes) { + my $taxnum = $tax->taxnum; + $self->{taxes}->{$taxnum} ||= [ $tax ]; + $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum ); push @{ $self->{taxes}->{$taxnum} }, $cust_bill_pkg; } } diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 59899cf53..2479ef68b 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -697,6 +697,10 @@ sub print_generic { # XXX should be an FS::cust_bill method to set the defaults, instead # of checking the type here + # info from customer's last invoice before this one, for some + # summary formats + $invoice_data{'last_bill'} = {}; + my $last_bill = $self->previous_bill; if ( $last_bill ) { @@ -757,9 +761,7 @@ sub print_generic { # ($pr_total is used elsewhere but not as $previous_balance) $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); - $invoice_data{'last_bill'} = { - '_date' => $last_bill->_date, #unformatted - }; + $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted my (@payments, @credits); # for formats that itemize previous payments foreach my $cust_pay ( qsearch('cust_pay', { @@ -801,11 +803,7 @@ sub print_generic { $invoice_data{'previous_payments'} = []; $invoice_data{'previous_credits'} = []; } - - # info from customer's last invoice before this one, for some - # summary formats - $invoice_data{'last_bill'} = {}; - + if ( $conf->exists('invoice_usesummary', $agentnum) ) { $invoice_data{'summarypage'} = $summarypage = 1; } diff --git a/FS/FS/cdr/earthlink.pm b/FS/FS/cdr/earthlink.pm index 0421ef935..da0d54527 100644 --- a/FS/FS/cdr/earthlink.pm +++ b/FS/FS/cdr/earthlink.pm @@ -24,6 +24,7 @@ use Date::Parse; my $datetime = $date. " ". $time; $cdr->set('startdate', $datetime ); }, #time + skip(1), #TollFreeNumber sub { my($cdr, $src) = @_; $src =~ s/\D//g; $cdr->set('src', $src); diff --git a/FS/FS/cdr/ispphone.pm b/FS/FS/cdr/ispphone.pm new file mode 100644 index 000000000..49d1b07f4 --- /dev/null +++ b/FS/FS/cdr/ispphone.pm @@ -0,0 +1,51 @@ +package FS::cdr::ispphone; + +use strict; +use vars qw( @ISA %info $tmp_mon $tmp_mday $tmp_year ); +use Time::Local; +use FS::cdr; +use Date::Parse; + +@ISA = qw(FS::cdr); + +%info = ( + 'name' => 'ISPPhone', + 'weight' => 123, + 'header' => 2, + 'import_fields' => [ + + 'src', # Form + 'dst', # To + 'upstream_dst_regionname', # Country + 'dcontext', # Description + + sub { my ($cdr, $calldate) = @_; + $cdr->set('calldate', $calldate); + + my $tmp_date; + + if ($calldate =~ /^(\d{2})\/(\d{2})\/(\d{2})\s*(\d{1,2}):(\d{2})$/){ + + $tmp_date = "$2/$1/$3 $4:$5:$6"; + + } else { $tmp_date = $calldate; } + + $tmp_date = str2time($tmp_date); + $cdr->set('startdate', $tmp_date); + + }, #DateTime + + sub { my ($cdr, $duration) = @_; + my ($min,$sec) = split(/:/, $duration); + my $billsec = $sec + $min * 60; + $cdr->set('billsec', $billsec); + + }, #Charged time, min:sec + + 'upstream_price', # Amount ( upstream price ) +], + +); + +1; + diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index 7257a9bb8..d0cec90dc 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -202,10 +202,14 @@ sub insert { } } - my $tax_location = $self->get('cust_bill_pkg_tax_location'); - if ( $tax_location ) { + foreach my $tax_link_table (qw(cust_bill_pkg_tax_location + cust_bill_pkg_tax_rate_location)) + { + my $tax_location = $self->get($tax_link_table) || []; foreach my $link ( @$tax_location ) { - next if $link->billpkgtaxlocationnum; # don't try to double-insert + $DB::single=1; #XXX + my $pkey = $link->primary_key; + next if $link->get($pkey); # don't try to double-insert # This cust_bill_pkg can be linked on either side (i.e. it can be the # tax or the taxed item). If the other side is already inserted, # then set billpkgnum to ours, and insert the link. Otherwise, @@ -221,8 +225,8 @@ sub insert { my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) { $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum); - # XXX if we ever do tax-on-tax for these, this will have to change - # since pkgnum will be zero + # XXX pkgnum is zero for tax on tax; it might be better to use + # the underlying package? $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum); $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum); $link->set('taxable_cust_bill_pkg', ''); @@ -235,29 +239,29 @@ sub insert { return "error inserting cust_bill_pkg_tax_location: $error"; } } else { # handoff - my $other; + my $other; # the as yet uninserted cust_bill_pkg $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg') : $link->get('tax_cust_bill_pkg'); - my $link_array = $other->get('cust_bill_pkg_tax_location') || []; + my $link_array = $other->get( $tax_link_table ) || []; push @$link_array, $link; - $other->set('cust_bill_pkg_tax_location' => $link_array); + $other->set( $tax_link_table => $link_array); } } #foreach my $link } # someday you will be as awesome as cust_bill_pkg_tax_location... - # but not today - my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location'); - if ( $tax_rate_location ) { - foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) { - $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum); - $error = $cust_bill_pkg_tax_rate_location->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "error inserting cust_bill_pkg_tax_rate_location: $error"; - } - } - } + # and today is that day + #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location'); + #if ( $tax_rate_location ) { + # foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) { + # $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum); + # $error = $cust_bill_pkg_tax_rate_location->insert; + # if ( $error ) { + # $dbh->rollback if $oldAutoCommit; + # return "error inserting cust_bill_pkg_tax_rate_location: $error"; + # } + # } + #} my $fee_links = $self->get('cust_bill_pkg_fee'); if ( $fee_links ) { @@ -295,13 +299,12 @@ sub insert { } # 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 ( my $fee_origin = $self->get('fee_origin') ) { + $fee_origin->set('billpkgnum' => $self->billpkgnum); + $error = $fee_origin->replace; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "error updating cust_event_fee: $error"; + return "error updating fee origin record: $error"; } } @@ -557,6 +560,138 @@ sub regularize_details { return; } +=item set_exemptions TAXOBJECT, OPTIONS + +Sets up tax exemptions. TAXOBJECT is the L or +L record for the tax. + +This will deal with the following cases: + +=over 4 + +=item Fully exempt customers (cust_main.tax flag) or customer classes +(cust_class.tax). + +=item Customers exempt from specific named taxes (cust_main_exemption +records). + +=item Taxes that don't apply to setup or recurring fees +(cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax). + +=item Packages that are marked as tax-exempt (part_pkg.setuptax, +part_pkg.recurtax). + +=item Fees that aren't marked as taxable (part_fee.taxable). + +=back + +It does NOT deal with monthly tax exemptions, which need more context +than this humble little method cares to deal with. + +OPTIONS should include "custnum" => the customer number if this tax line +hasn't been inserted (which it probably hasn't). + +Returns a list of exemption objects, which will also be attached to the +line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line +item will insert these records as well. + +=cut + +sub set_exemptions { + my $self = shift; + my $tax = shift; + my %opt = @_; + + my $part_pkg = $self->part_pkg; + my $part_fee = $self->part_fee; + + my $cust_main; + my $custnum = $opt{custnum}; + $custnum ||= $self->cust_bill->custnum if $self->cust_bill; + + $cust_main = FS::cust_main->by_key( $custnum ) + or die "set_exemptions can't identify customer (pass custnum option)\n"; + + my @new_exemptions; + my $taxable_charged = $self->setup + $self->recur; + return unless $taxable_charged > 0; + + ### Fully exempt customer ### + my $exempt_cust; + my $conf = FS::Conf->new; + if ( $conf->exists('cust_class-tax_exempt') ) { + my $cust_class = $cust_main->cust_class; + $exempt_cust = $cust_class->tax if $cust_class; + } else { + $exempt_cust = $cust_main->tax; + } + + ### Exemption from named tax ### + my $exempt_cust_taxname; + if ( !$exempt_cust and $tax->taxname ) { + $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname); + } + + if ( $exempt_cust ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $taxable_charged, + exempt_cust => 'Y', + }); + $taxable_charged = 0; + + } elsif ( $exempt_cust_taxname ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $taxable_charged, + exempt_cust_taxname => 'Y', + }); + $taxable_charged = 0; + + } + + my $exempt_setup = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->setuptax) + or $tax->setuptax ); + + if ( $exempt_setup + and $self->setup > 0 + and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $self->setup, + exempt_setup => 'Y' + }); + $taxable_charged -= $self->setup; + + } + + my $exempt_recur = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->recurtax) + or $tax->recurtax ); + + if ( $exempt_recur + and $self->recur > 0 + and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $self->recur, + exempt_recur => 'Y' + }); + $taxable_charged -= $self->recur; + + } + + foreach (@new_exemptions) { + $_->set('taxnum', $tax->taxnum); + $_->set('taxtype', ref($tax)); + } + + push @{ $self->cust_tax_exempt_pkg }, @new_exemptions; + return @new_exemptions; + +} + =item cust_bill Returns the invoice (see L) for this invoice line item. @@ -811,71 +946,47 @@ recur) of charge. sub disintegrate { my $self = shift; # XXX this goes away with cust_bill_pkg refactor + # or at least I wish it would, but it turns out to be harder than + # that. - my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; + #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh? my %cust_bill_pkg = (); - $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup; - $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur; - - - #split setup and recur - if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) { - my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash }; - $cust_bill_pkg->set('details', []); - $cust_bill_pkg->recur(0); - $cust_bill_pkg->unitrecur(0); - $cust_bill_pkg->type(''); - $cust_bill_pkg_recur->setup(0); - $cust_bill_pkg_recur->unitsetup(0); - $cust_bill_pkg{recur} = $cust_bill_pkg_recur; - + my $usage_total; + foreach my $classnum ($self->usage_classes) { + my $amount = $self->usage($classnum); + next if $amount == 0; # though if so we shouldn't be here + my $usage_item = FS::cust_bill_pkg->new({ + $self->hash, + 'setup' => 0, + 'recur' => $amount, + 'taxclass' => $classnum, + 'inherit' => $self + }); + $cust_bill_pkg{$classnum} = $usage_item; + $usage_total += $amount; } - #split usage from recur - my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage ) - if exists($cust_bill_pkg{recur}); - warn "usage is $usage\n" if $DEBUG > 1; - if ($usage) { - my $cust_bill_pkg_usage = - new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash }; - $cust_bill_pkg_usage->recur( $usage ); - $cust_bill_pkg_usage->type( 'U' ); - my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage ); - $cust_bill_pkg{recur}->recur( $recur ); - $cust_bill_pkg{recur}->type( '' ); - $cust_bill_pkg{recur}->set('details', []); - $cust_bill_pkg{''} = $cust_bill_pkg_usage; + foreach (qw(setup recur)) { + next if ($self->get($_) == 0); + my $item = FS::cust_bill_pkg->new({ + $self->hash, + 'setup' => 0, + 'recur' => 0, + 'taxclass' => $_, + 'inherit' => $self, + }); + $item->set($_, $self->get($_)); + $cust_bill_pkg{$_} = $item; } - #subdivide usage by usage_class - if (exists($cust_bill_pkg{''})) { - foreach my $class (grep { $_ } $self->usage_classes) { - my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) ); - my $cust_bill_pkg_usage = - new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash }; - $cust_bill_pkg_usage->recur( $usage ); - $cust_bill_pkg_usage->set('details', []); - my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage ); - $cust_bill_pkg{''}->recur( $classless ); - $cust_bill_pkg{$class} = $cust_bill_pkg_usage; - } - warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur - if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0); - delete $cust_bill_pkg{''} - unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0); + if ($usage_total) { + $cust_bill_pkg{recur}->set('recur', + sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total) + ); } -# # sort setup,recur,'', and the rest numeric && return -# my @result = map { $cust_bill_pkg{$_} } -# sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/); -# ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a ) -# } -# keys %cust_bill_pkg; -# -# return (@result); - - %cust_bill_pkg; + %cust_bill_pkg; } =item usage CLASSNUM @@ -950,7 +1061,7 @@ sub usage_classes { sub cust_tax_exempt_pkg { my ( $self ) = @_; - $self->{Hash}->{cust_tax_exempt_pkg} ||= []; + my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= []; } =item cust_bill_pkg_tax_Xlocation diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm index e88dcc4b7..375a533e9 100644 --- a/FS/FS/cust_event_fee.pm +++ b/FS/FS/cust_event_fee.pm @@ -1,7 +1,7 @@ package FS::cust_event_fee; use strict; -use base qw( FS::Record ); +use base qw( FS::Record FS::FeeOrigin_Mixin ); use FS::Record qw( qsearch qsearchs ); =head1 NAME @@ -27,8 +27,8 @@ FS::cust_event_fee - Object methods for cust_event_fee records 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: +L object). FS::cust_event_fee inherits from FS::Record +and FS::FeeOrigin_Mixin. The following fields are currently supported: =over 4 @@ -85,9 +85,6 @@ and replace methods. =cut -# the check method should currently be supplied - FS::Record contains some -# data checking routines - sub check { my $self = shift; @@ -109,18 +106,14 @@ sub check { =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, cust_pkg, or cust_bill events; this will return -all of them. +=item _by_cust CUSTNUM[, PARAMS] -PARAMS can be additional params to pass to qsearch; this really only works -for 'hashref' and 'order_by'. +See L. This is the implementation for +event-triggered fees. =cut -sub by_cust { +sub _by_cust { my $class = shift; my $custnum = shift or return; my %params = @_; @@ -167,13 +160,45 @@ sub by_cust { }) } - +=item cust_bill + +See L. This version simply returns the event +object if the event is an invoice event. + +=cut + +sub cust_bill { + my $self = shift; + my $object = $self->cust_event->cust_X; + if ( $object->isa('FS::cust_bill') ) { + return $object; + } else { + return ''; + } +} + +=item cust_pkg + +See L. This version simply returns the event +object if the event is a package event. + +=cut + +sub cust_pkg { + my $self = shift; + my $object = $self->cust_event->cust_X; + if ( $object->isa('FS::cust_pkg') ) { + return $object; + } else { + return ''; + } +} =head1 BUGS =head1 SEE ALSO -L, L, L +L, L, L =cut diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 87499a91a..8f6234880 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -8,6 +8,7 @@ use List::Util qw( min ); use FS::UID qw( dbh ); use FS::Record qw( qsearch qsearchs dbdef ); use FS::Misc::DateTime qw( day_end ); +use Tie::RefHash; use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; @@ -21,7 +22,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::FeeOrigin_Mixin; use FS::Log; use FS::TaxEngine; @@ -601,17 +602,17 @@ sub bill { # process fees ### - my @pending_event_fees = FS::cust_event_fee->by_cust($self->custnum, + my @pending_fees = FS::FeeOrigin_Mixin->by_cust($self->custnum, hashref => { 'billpkgnum' => '' } ); - warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n" - if @pending_event_fees and $DEBUG > 1; + warn "$me found pending fees:\n".Dumper(\@pending_fees)."\n" + if @pending_fees and $DEBUG > 1; # determine whether to generate an invoice my $generate_bill = scalar(@cust_bill_pkg) > 0; - foreach my $event_fee (@pending_event_fees) { - $generate_bill = 1 unless $event_fee->nextbill; + foreach my $fee (@pending_fees) { + $generate_bill = 1 unless $fee->nextbill; } # don't create an invoice with no line items, or where the only line @@ -620,38 +621,11 @@ sub bill { # calculate fees... my @fee_items; - foreach my $event_fee (@pending_event_fees) { - my $object = $event_fee->cust_event->cust_X; - my $part_fee = $event_fee->part_fee; - my $cust_bill; - if ( $object->isa('FS::cust_main') - or $object->isa('FS::cust_pkg') - or $object->isa('FS::cust_pay_batch') ) - { - # 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} }, - }); - - # If this is a package event, only apply the fee to line items - # from that package. - if ($object->isa('FS::cust_pkg')) { - $cust_bill->set('cust_bill_pkg', - [ grep { $_->pkgnum == $object->pkgnum } @cust_bill_pkg ] - ); - } + foreach my $fee_origin (@pending_fees) { + my $part_fee = $fee_origin->part_fee; - } elsif ( $object->isa('FS::cust_bill') ) { - # simple case: applying the fee to a previous invoice (late fee, - # etc.) - $cust_bill = $object; - } + # check whether the fee is applicable before doing anything expensive: + # # 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. @@ -662,10 +636,41 @@ sub bill { } # also skip if it's disabled next if $part_fee->disabled eq 'Y'; + + # Decide which invoice to base the fee on. + my $cust_bill = $fee_origin->cust_bill; + if (!$cust_bill) { + # Then link it to the current invoice. This isn't 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} }, + }); + + # If the origin is for a specific package, then only apply the fee to + # line items from that package. + if ( my $cust_pkg = $fee_origin->cust_pkg ) { + my @charge_fee_on_item; + my $charge_fee_on_amount = 0; + foreach (@cust_bill_pkg) { + if ($_->pkgnum == $cust_pkg->pkgnum) { + push @charge_fee_on_item, $_; + $charge_fee_on_amount += $_->setup + $_->recur; + } + } + $cust_bill->set('cust_bill_pkg', \@charge_fee_on_item); + $cust_bill->set('charged', $charge_fee_on_amount); + } + + } # $cust_bill is now set # calculate the fee my $fee_item = $part_fee->lineitem($cust_bill) or next; # link this so that we can clear the marker on inserting the line item - $fee_item->set('cust_event_fee', $event_fee); + $fee_item->set('fee_origin', $fee_origin); push @fee_items, $fee_item; } @@ -1385,6 +1390,11 @@ 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). +This method will also calculate exemptions for any taxes that apply to the +line item (using the C method of L) and +attach them. This is the only place C is called in normal +invoice processing. + =cut sub _handle_taxes { @@ -1414,85 +1424,73 @@ sub _handle_taxes { my %taxes = (); my @classes; - push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + my $usage = $cust_bill_pkg->usage || 0; + push @classes, $cust_bill_pkg->usage_classes if $usage; push @classes, 'setup' if $cust_bill_pkg->setup and !$options{cancel}; - push @classes, 'recur' if $cust_bill_pkg->recur and !$options{cancel}; - - my $exempt = $conf->exists('cust_class-tax_exempt') - ? ( $self->cust_class ? $self->cust_class->tax : '' ) - : $self->tax; + push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) + and !$options{cancel}; + # that's better--probably don't even need $options{cancel} now + # but leave it for now, just to be safe + # + # About $options{cancel}: This protects against charging per-line or + # per-customer or other flat-rate surcharges on a package that's being + # billed on cancellation (which is an out-of-cycle bill and should only + # have usage charges). See RT#29443. + + # customer exemption is now handled in the 'taxline' method + #my $exempt = $conf->exists('cust_class-tax_exempt') + # ? ( $self->cust_class ? $self->cust_class->tax : '' ) + # : $self->tax; # standardize this just to be sure - $exempt = ($exempt eq 'Y') ? 'Y' : ''; - - if ( !$exempt ) { + #$exempt = ($exempt eq 'Y') ? 'Y' : ''; + # + #if ( !$exempt ) { + + unless (exists $taxes{''}) { + # unsure what purpose this serves, but last time I deleted something + # from here just because I didn't see the point, it actually did + # something important. + my $err_or_ref = $self->_gather_taxes($part_item, '', $location); + return $err_or_ref unless ref($err_or_ref); + $taxes{''} = $err_or_ref; + } - foreach my $class (@classes) { - 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; - } + # NO DISINTEGRATIONS. + # my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; + # + # do not call taxline() with any argument except the entire set of + # cust_bill_pkgs on an invoice that are eligible for the tax. - unless (exists $taxes{''}) { - my $err_or_ref = $self->_gather_taxes($part_item, '', $location); - return $err_or_ref unless ref($err_or_ref); - $taxes{''} = $err_or_ref; - } + # only calculate exemptions once for each tax rate, even if it's used + # for multiple classes + my %tax_seen = (); + + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); + return $err_or_ref unless ref($err_or_ref); + my @taxes = @$err_or_ref; - } + next if !@taxes; - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr - foreach my $key (keys %tax_cust_bill_pkg) { - # $key is "setup", "recur", or a usage class name. ('' is a usage class.) - # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of - # the line item. - # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that - # apply to $key-class charges. - my @taxes = @{ $taxes{$key} || [] }; - my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; - - my %localtaxlisthash = (); foreach my $tax ( @taxes ) { - # this is the tax identifier, not the taxname - my $taxname = ref( $tax ). ' '. $tax->taxnum; - # $taxlisthash: keys are "setup", "recur", and usage classes. + my $tax_id = ref( $tax ). ' '. $tax->taxnum; + # $taxlisthash: keys are tax identifiers ('FS::tax_rate 123456'). # Values are arrayrefs, first the tax object (cust_main_county - # or tax_rate) and then any cust_bill_pkg objects that the - # tax applies to. - $taxlisthash->{ $taxname } ||= [ $tax ]; - push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; - - $localtaxlisthash{ $taxname } ||= [ $tax ]; - push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg; - - } + # or tax_rate), then the cust_bill_pkg object that the + # tax applies to, then the tax class (setup, recur, usage classnum). + $taxlisthash->{ $tax_id } ||= [ $tax ]; + push @{ $taxlisthash->{ $tax_id } }, $cust_bill_pkg, $class; + + # determine any exemptions that apply + if (!$tax_seen{$tax_id}) { + $cust_bill_pkg->set_exemptions( $tax, custnum => $self->custnum ); + $tax_seen{$tax_id} = 1; + } - warn "finding taxed taxes...\n" if $DEBUG > 2; - foreach my $tax ( keys %localtaxlisthash ) { - my $tax_object = shift @{ $localtaxlisthash{$tax} }; - warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" - if $DEBUG > 2; - next unless $tax_object->can('tax_on_tax'); - - foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { - my $totname = ref( $tot ). ' '. $tot->taxnum; - - warn "checking $totname which we call ". $tot->taxname. " as applicable\n" - if $DEBUG > 2; - next unless exists( $localtaxlisthash{ $totname } ); # only increase - # existing 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} ); - return $hashref_or_error - unless ref($hashref_or_error); - - # and append it to the list of taxable items - $taxlisthash->{ $totname } ||= [ $tot ]; - push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount}; + # tax on tax will be done later, when we actually create the tax + # line items - } } } @@ -1532,6 +1530,7 @@ sub _handle_taxes { foreach (@taxes) { my $tax_id = 'cust_main_county '.$_->taxnum; $taxlisthash->{$tax_id} ||= [ $_ ]; + $cust_bill_pkg->set_exemptions($_, custnum => $self->custnum); push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg; } diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index bafbb58f4..be5bdea8f 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -106,6 +106,8 @@ FS::cust_pkg - Object methods for cust_pkg objects $seconds = $record->seconds_since($timestamp); + #bulk cancel+order... perhaps slightly deprecated, only used by the bulk + # cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi) $error = FS::cust_pkg::order( $custnum, \@pkgparts ); $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] ); @@ -1332,6 +1334,7 @@ sub suspend { if $error; } + my $cust_pkg_reason; if ( $options{'reason'} ) { $error = $self->insert_reason( 'reason' => $options{'reason'}, 'action' => $date ? 'adjourn' : 'suspend', @@ -1342,6 +1345,11 @@ sub suspend { dbh->rollback if $oldAutoCommit; return "Error inserting cust_pkg_reason: $error"; } + $cust_pkg_reason = qsearchs('cust_pkg_reason', { + 'date' => $date ? $date : $suspend_time, + 'action' => $date ? 'A' : 'S', + 'pkgnum' => $self->pkgnum, + }); } # if a reasonnum was passed, get the actual reason object so we can check @@ -1422,6 +1430,27 @@ sub suspend { } } + # suspension fees: if there is a feepart, and it's not an unsuspend fee, + # and this is not a suspend-before-cancel + if ( $cust_pkg_reason ) { + my $reason_obj = $cust_pkg_reason->reason; + if ( $reason_obj->feepart and + ! $reason_obj->fee_on_unsuspend and + ! $options{'from_cancel'} ) { + + # register the need to charge a fee, cust_main->bill will do the rest + warn "registering suspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n" + if $DEBUG; + my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({ + 'pkgreasonnum' => $cust_pkg_reason->num, + 'pkgnum' => $self->pkgnum, + 'feepart' => $reason->feepart, + 'nextbill' => $reason->fee_hold, + }); + $error ||= $cust_pkg_reason_fee->insert; + } + } + my $conf = new FS::Conf; if ( $conf->config('suspend_email_admin') && !$options{'from_cancel'} ) { @@ -1719,23 +1748,39 @@ sub unsuspend { my $unsusp_pkg; - if ( $reason && $reason->unsuspend_pkgpart ) { - my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart) - or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart. - " not found."; - my $start_date = $self->cust_main->next_bill_date - if $reason->unsuspend_hold; - - if ( $part_pkg ) { - $unsusp_pkg = FS::cust_pkg->new({ - 'custnum' => $self->custnum, - 'pkgpart' => $reason->unsuspend_pkgpart, - 'start_date' => $start_date, - 'locationnum' => $self->locationnum, - # discount? probably not... + if ( $reason ) { + if ( $reason->unsuspend_pkgpart ) { + warn "Suspend reason '".$reason->reason."' uses deprecated unsuspend_pkgpart feature.\n"; + my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart) + or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart. + " not found."; + my $start_date = $self->cust_main->next_bill_date + if $reason->unsuspend_hold; + + if ( $part_pkg ) { + $unsusp_pkg = FS::cust_pkg->new({ + 'custnum' => $self->custnum, + 'pkgpart' => $reason->unsuspend_pkgpart, + 'start_date' => $start_date, + 'locationnum' => $self->locationnum, + # discount? probably not... + }); + + $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg ); + } + } + # new way, using fees + if ( $reason->feepart and $reason->fee_on_unsuspend ) { + # register the need to charge a fee, cust_main->bill will do the rest + warn "registering unsuspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n" + if $DEBUG; + my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({ + 'pkgreasonnum' => $cust_pkg_reason->num, + 'pkgnum' => $self->pkgnum, + 'feepart' => $reason->feepart, + 'nextbill' => $reason->fee_hold, }); - - $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg ); + $error ||= $cust_pkg_reason_fee->insert; } if ( $error ) { @@ -4687,6 +4732,9 @@ sub _X_show_zero { =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ] +Bulk cancel + order subroutine. Perhaps slightly deprecated, only used by the +bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi) + CUSTNUM is a customer (see L) PKGPARTS is a list of pkgparts specifying the the billing item definitions (see diff --git a/FS/FS/cust_pkg_reason_fee.pm b/FS/FS/cust_pkg_reason_fee.pm new file mode 100644 index 000000000..1155c15ea --- /dev/null +++ b/FS/FS/cust_pkg_reason_fee.pm @@ -0,0 +1,152 @@ +package FS::cust_pkg_reason_fee; + +use strict; +use base qw( FS::Record FS::FeeOrigin_Mixin ); +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_pkg_reason_fee - Object methods for cust_pkg_reason_fee records + +=head1 SYNOPSIS + + use FS::cust_pkg_reason_fee; + + $record = new FS::cust_pkg_reason_fee \%hash; + $record = new FS::cust_pkg_reason_fee { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_pkg_reason_fee object links a package status change that charged +a fee (an L object) to the resulting invoice line item. +FS::cust_pkg_reason_fee inherits from FS::Record and FS::FeeOrigin_Mixin. +The following fields are currently supported: + +=over 4 + +=item pkgreasonfeenum - primary key + +=item pkgreasonnum - key of the cust_pkg_reason object that triggered the fee. + +=item billpkgnum - key of the cust_bill_pkg record representing the fee on an +invoice. This can be NULL if the fee is scheduled but hasn't been billed yet. + +=item feepart - key of the fee definition (L). + +=item nextbill - 'Y' if the fee should be charged on the customer's next bill, +rather than causing a bill to be produced immediately. + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +=cut + +sub table { 'cust_pkg_reason_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; + + my $error = + $self->ut_numbern('pkgreasonfeenum') + || $self->ut_foreign_key('pkgreasonnum', 'cust_pkg_reason', 'num') + || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg', 'billpkgnum') + || $self->ut_foreign_key('feepart', 'part_fee', 'feepart') + || $self->ut_flag('nextbill') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 CLASS METHODS + +=over 4 + +=item _by_cust CUSTNUM[, PARAMS] + +See L. + +=cut + +sub _by_cust { + my $class = shift; + my $custnum = shift or return; + my %params = @_; + $custnum =~ /^\d+$/ or die "bad custnum $custnum"; + + my $where = ($params{hashref} && keys (%{ $params{hashref} })) + ? 'AND' + : 'WHERE'; + qsearch({ + table => 'cust_pkg_reason_fee', + addl_from => 'JOIN cust_pkg_reason ON (cust_pkg_reason_fee.pkgreasonnum = cust_pkg_reason.num) ' . + 'JOIN cust_pkg USING (pkgnum) ', + extra_sql => "$where cust_pkg.custnum = $custnum", + %params + }); +} + +=back + +=head1 METHODS + +=over 4 + +=item cust_pkg + +Returns the package that triggered the fee. + +=cut + +sub cust_pkg { + my $self = shift; + $self->cust_pkg_reason->cust_pkg; +} + +=head1 SEE ALSO + +L, L, L + +=cut + +1; + diff --git a/FS/FS/part_export/a2billing.pm b/FS/FS/part_export/a2billing.pm index 0821a34a0..15410aebf 100644 --- a/FS/FS/part_export/a2billing.pm +++ b/FS/FS/part_export/a2billing.pm @@ -224,7 +224,7 @@ sub export_insert { id_cc_didgroup => $self->option('didgroup'), id_cc_country => $cc_country_id, iduser => $cc_card_id, - did => $svc->phonenum, + did => $svc->countrycode. $svc->phonenum, billingtype => ($self->option('billtype') eq 'Dial Out Rate' ? 2 : 3), activated => 1, aleg_carrier_cost_min_offp => $part_pkg->option('a2billing_carrier_cost_min'), @@ -242,7 +242,7 @@ sub export_insert { my $cc_did_id = $self->a2b_find('cc_did', 'svcnum', $svc->svcnum); - my $destination = 'SIP/user-'. $svc_acct->username. '@'. $svc->sip_server. "!". $svc->phonenum; + my $destination = 'SIP/user-'. $svc_acct->username. '@'. $svc->sip_server. "!". $svc->countrycode. $svc->phonenum; my %cc_did_destination = ( destination => $destination, priority => 1, @@ -408,7 +408,7 @@ sub export_replace { } elsif ( $new->isa('FS::svc_phone') ) { # if the phone number has changed, need to create a new DID. - if ( $new->phonenum ne $old->phonenum ) { + if ( $new->phonenum ne $old->phonenum || $new->countrycode ne $old->countrycode ) { # deactivate/unlink/close the old DID # and create/link the new one $error = $self->export_delete($old) diff --git a/FS/FS/part_export/cacti.pm b/FS/FS/part_export/cacti.pm index 6877c8f5f..1f5f64c2a 100644 --- a/FS/FS/part_export/cacti.pm +++ b/FS/FS/part_export/cacti.pm @@ -1,10 +1,16 @@ package FS::part_export::cacti; use strict; + use base qw( FS::part_export ); use FS::Record qw( qsearchs ); use FS::UID qw( dbh ); +use File::Rsync; +use File::Slurp qw( append_file slurp write_file ); +use File::stat; +use MIME::Base64 qw( encode_base64 ); + use vars qw( %info ); my $php = 'php -q '; @@ -14,14 +20,18 @@ tie my %options, 'Tie::IxHash', default => 'freeside' }, 'script_path' => { label => 'Script Path', default => '/usr/share/cacti/cli/' }, - 'base_url' => { label => 'Base Cacti URL', - default => '' }, 'template_id' => { label => 'Host Template ID', default => '' }, - 'tree_id' => { label => 'Graph Tree ID', + 'tree_id' => { label => 'Graph Tree ID (optional)', default => '' }, 'description' => { label => 'Description (can use $ip_addr and $description tokens)', default => 'Freeside $description $ip_addr' }, + 'graphs_path' => { label => 'Graph Export Directory (user@host:/path/to/graphs/)', + default => '' }, + 'import_freq' => { label => 'Minimum minutes between graph imports', + default => '5' }, + 'max_graph_size' => { label => 'Maximum size per graph (MB)', + default => '5' }, # 'delete_graphs' => { label => 'Delete associated graphs and data sources when unprovisioning', # type => 'checkbox', # }, @@ -155,27 +165,18 @@ sub ssh_insert { my $id = $1; # Add host to tree - $cmd = $php - . $opt{'script_path'} - . q(add_tree.php --type=node --node-type=host --tree-id=) - . $opt{'tree_id'} - . q( --host-id=) - . $id; - $response = ssh_cmd(%opt, 'command' => $cmd); - unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) { + if ($opt{'tree_id'}) { + $cmd = $php + . $opt{'script_path'} + . q(add_tree.php --type=node --node-type=host --tree-id=) + . $opt{'tree_id'} + . q( --host-id=) + . $id; + $response = ssh_cmd(%opt, 'command' => $cmd); + unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) { die "Error adding host to tree: $response"; + } } - my $leaf_id = $1; - - # Store id for generating graph urls - my $svc_broadband = qsearchs({ - 'table' => 'svc_broadband', - 'hashref' => { 'svcnum' => $opt{'svcnum'} }, - }); - die "Could not reload broadband service" unless $svc_broadband; - $svc_broadband->set('cacti_leaf_id',$leaf_id); - my $error = $svc_broadband->replace; - return $error if $error; # # Get list of graph templates for new id # $cmd = $php @@ -237,6 +238,145 @@ sub ssh_delete { return ''; } +# NOT A METHOD, run as an FS::queue job +# copies graphs for a single service from Cacti export directory to FS cache +# generates basic html pages for this service's graphs, and stores them in FS cache +sub process_graphs { + my ($job,$param) = @_; # + + $job->update_statustext(10); + my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/'; + + # load the service + my $svcnum = $param->{'svcnum'} || die "No svcnum specified"; + my $svc = qsearchs({ + 'table' => 'svc_broadband', + 'hashref' => { 'svcnum' => $svcnum }, + }) || die "Could not load svcnum $svcnum"; + + # load relevant FS::part_export::cacti object + my ($self) = $svc->cust_svc->part_svc->part_export('cacti'); + + $job->update_statustext(20); + + # check for recent uploads, avoid doing this too often + my $svchtml = $cachedir.'svc_'.$svcnum.'.html'; + if (-e $svchtml) { + open(my $fh, "<$svchtml"); + my $firstline = <$fh>; + close($fh); + if ($firstline =~ /UPDATED (\d+)/) { + if ($1 > time - 60 * ($self->option('import_freq') || 5)) { + $job->update_statustext(100); + return ''; + } + } + } + + $job->update_statustext(30); + + # get list of graphs for this svc + my $cmd = $php + . $self->option('script_path') + . q(freeside_cacti.php --get-graphs --ip=') + . $svc->ip_addr + . q('); + my @graphs = map { [ split(/\t/,$_) ] } + split(/\n/, ssh_cmd( + 'host' => $self->machine, + 'user' => $self->option('user'), + 'command' => $cmd + )); + + $job->update_statustext(40); + + # copy graphs to cache + # requires version 2.6.4 of rsync, released March 2005 + my $rsync = File::Rsync->new({ + 'rsh' => 'ssh', + 'verbose' => 1, + 'recursive' => 1, + 'source' => $self->option('graphs_path'), + 'dest' => $cachedir, + 'include' => [ + (map { q('**graph_).${$_}[0].q(*.png') } @graphs), + (map { q('**thumb_).${$_}[0].q(.png') } @graphs), + q('*/'), + q('- *'), + ], + }); + #don't know why a regular $rsync->exec isn't doing includes right, but this does + my $error = system(join(' ',@{$rsync->getcmd()})); + die "rsync failed with exit status $error" if $error; + + $job->update_statustext(50); + + # create html files in cache + my $now = time; + my $svchead = q(\n) + . '

Service #' . $svcnum . '

' . "\n" + . q(

Last updated ) . scalar(localtime($now)) . q(

) . "\n"; + write_file($svchtml,$svchead); + my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5); + my $nographs = 1; + for (my $i = 0; $i <= $#graphs; $i++) { + my $graph = $graphs[$i]; + my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png'; + if ( + (-e $thumbfile) && + ( stat($thumbfile)->size() < $maxgraph ) + ) { + $nographs = 0; + # add graph to main file + my $graphhead = q(

) . $$graph[1] . q(

) . "\n"; + append_file( $svchtml, $graphhead, + anchor_tag( + $svcnum, $$graph[0], img_tag($thumbfile) + ) + ); + # create graph details file + my $graphhtml = $cachedir . 'svc_' . $svcnum . '_graph_' . $$graph[0] . '.html'; + write_file($graphhtml,$svchead,$graphhead); + my $nodetail = 1; + my $j = 1; + while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) { + if ( stat($graphfile)->size() < $maxgraph ) { + $nodetail = 0; + append_file( $graphhtml, img_tag($graphfile) ); + } + $j++; + } + append_file($graphhtml, '

No detail graphs to display for this graph

') + if $nodetail; + } + $job->update_statustext(50 + ($i / $#graphs) * 50); + } + append_file($svchtml,'

No graphs to display for this service

') + if $nographs; + + $job->update_statustext(100); + return ''; +} + +sub img_tag { + my $somefile = shift; + return q(
\n); +} + +sub anchor_tag { + my ($svcnum, $graphnum, $contents) = @_; + return q() + . $contents + . q(); +} + +#this gets used by everything else #fake false laziness, other ssh_cmds handle error/output differently sub ssh_cmd { use Net::OpenSSH; @@ -274,41 +414,56 @@ the same permissions as the other files in that directory, and create (or choose an existing) user with sufficient permission to read these scripts. In the regular Cacti interface, create a Host Template to be used by -devices exported by Freeside, and note the template's id number. +devices exported by Freeside, and note the template's id number. Optionally, +create a Graph Tree for these devices to be automatically added to, and note +the tree's id number. Configure a Graph Export (under Settings) and note +the Export Directory. In Freeside, go to Configuration->Services->Provisioning exports to add a new export. From the Add Export page, select cacti for Export then enter... -* the User Name with permission to run scripts in the cli directory +* the Hostname or IP address of your Cacti server -* enter the full Script Path to that directory (eg /usr/share/cacti/cli/) +* the User Name with permission to run scripts in the cli directory -* enter the Base Cacti URL for your cacti server (eg https://example.com/cacti/) +* the full Script Path to that directory (eg /usr/share/cacti/cli/) * the Host Template ID for adding new devices -* the Graph Tree ID for adding new devices +* the Graph Tree ID for adding new devices (optional) * the Description for new devices; you can use the tokens $ip_addr and $description to include the equivalent fields from the broadband service definition +* the Graph Export Directory, including connection information + if necessary (user@host:/path/to/graphs/) + +* the minimum minutes between graph imports to Freeside (graphs will + otherwise be imported into Freeside as needed.) This should be at least + as long as the minumum time between graph exports configured in Cacti. + Defaults to 5 if unspecified. + +* the maximum size per graph, in MB; individual graphs that exceed this size + will be quietly ignored by Freeside. Defaults to 5 if unspecified. + After adding the export, go to Configuration->Services->Service definitions. The export you just created will be available for selection when adding or -editing broadband service definitions. +editing broadband service definitions; check the box to activate it for +a given service. Note that you should only have one cacti export per +broadband service definition. -When properly configured broadband services are provisioned, they should now -be added to Cacti using the Host Template you specified, and the created device -will also be added to the specified Graph Tree. +When properly configured broadband services are provisioned, they will now +be added to Cacti using the Host Template you specified. If you also specified +a Graph Tree, the created device will also be added to that. Once added, a link to the graphs for this host will be available when viewing -the details of the provisioned service in Freeside (you will need to authenticate -into Cacti to view them.) +the details of the provisioned service in Freeside. Devices will be deleted from Cacti when the service is unprovisioned in Freeside, and they will be deleted and re-added if the ip address changes. -Currently, graphs themselves must still be added in cacti by hand or some +Currently, graphs themselves must still be added in Cacti by hand or some other form of automation tailored to your specific graph inputs and data sources. =head1 AUTHOR @@ -320,8 +475,8 @@ jonathan@freeside.biz Copyright 2015 Freeside Internet Services -This program is free software; you can redistribute it and/or | -modify it under the terms of the GNU General Public License | +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License as published by the Free Software Foundation. =cut diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index 449ea2221..a7628f6e0 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -558,7 +558,14 @@ sub import_from_gateway { my $processor = $gateway->batch_processor(%proc_opt); - my @batches = $processor->receive; + my @processor_ids = map { $_->processor_id } + qsearch({ + 'table' => 'pay_batch', + 'hashref' => { 'status' => 'I' }, + 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL) + }); + + my @batches = $processor->receive(@processor_ids); my $num = 0; @@ -1044,6 +1051,11 @@ sub export_to_gateway { ); $processor->submit($batch); + if ($batch->processor_id) { + $self->set('processor_id',$batch->processor_id); + $self->replace; + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; } diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm index 95b7c40c8..afae2667e 100644 --- a/FS/FS/payment_gateway.pm +++ b/FS/FS/payment_gateway.pm @@ -268,6 +268,13 @@ sub batch_processor { eval "use Business::BatchPayment;"; die "couldn't load Business::BatchPayment: $@" if $@; + #false laziness with processor + foreach (qw(username password)) { + if (length($self->get("gateway_$_"))) { + $opt{$_} = $self->get("gateway_$_"); + } + } + my $module = $self->gateway_module; my $processor = eval { Business::BatchPayment->create($module, $self->options, %opt) diff --git a/FS/FS/reason.pm b/FS/FS/reason.pm index 9c34dd98a..6f4bf62d9 100644 --- a/FS/FS/reason.pm +++ b/FS/FS/reason.pm @@ -50,7 +50,7 @@ FS::Record. The following fields are currently supported: L) of a package to be ordered when the package is unsuspended. Typically this will be some kind of reactivation fee. Attaching it to a suspension reason allows the reactivation fee to be charged for some -suspensions but not others. +suspensions but not others. DEPRECATED. =item unsuspend_hold - 'Y' or ''. If unsuspend_pkgpart is set, this tells whether to bill the unsuspend package immediately ('') or to wait until @@ -60,6 +60,15 @@ the customer's next invoice ('Y'). If enabled, the customer will be credited for their remaining time on suspension. +=item feepart - for suspension reasons, the feepart of a fee to be +charged when a package is suspended for this reason. + +=item fee_hold - 'Y' or ''. If feepart is set, tells whether to bill the fee +immediately ('') or wait until the customer's next invoice ('Y'). + +=item fee_on_unsuspend - If feepart is set, tells whether to charge the fee +on suspension ('') or unsuspension ('Y'). + =back =head1 METHODS @@ -121,10 +130,14 @@ sub check { || $self->ut_foreign_keyn('unsuspend_pkgpart', 'part_pkg', 'pkgpart') || $self->ut_flag('unsuspend_hold') || $self->ut_flag('unused_credit') + || $self->ut_foreign_keyn('feepart', 'part_fee', 'feepart') + || $self->ut_flag('fee_on_unsuspend') + || $self->ut_flag('fee_hold') ; return $error if $error; } else { - foreach (qw(unsuspend_pkgpart unsuspend_hold unused_credit)) { + foreach (qw(unsuspend_pkgpart unsuspend_hold unused_credit feepart + fee_on_unsuspend fee_hold)) { $self->set($_ => ''); } } @@ -192,7 +205,6 @@ sub new_or_existing { $reason; } - =head1 BUGS =head1 SEE ALSO diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 0047f9d5f..8579020e1 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -18,6 +18,7 @@ use HTTP::Response; use DBIx::DBSchema; use DBIx::DBSchema::Table; use DBIx::DBSchema::Column; +use List::Util 'sum'; use FS::Record qw( qsearch qsearchs dbh dbdef ); use FS::Conf; use FS::tax_class; @@ -379,57 +380,66 @@ sub passtype_name { $tax_passtypes{$self->passtype}; } -=item taxline_cch TAXABLES, [ OPTIONSHASH ] +=item taxline_cch TAXABLES, CLASSES -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 -is returned as a scalar. +Takes an arrayref of L objects representing taxable line +items, and an arrayref of charge classes ('setup', 'recur', '' for +unclassified usage, or an L number). Calculates the tax on +each item under this tax definition and returns a list of new +L objects for the taxes charged. Each returned object +will have a pseudo-field, "cust_bill_pkg_tax_rate_location", containing a +single L object linking the tax rate +back to this tax, and to its originating sale. + +If the taxable objects are linked to an invoice, this will also calculate +per-customer exemptions (cust_exempt and cust_taxname_exempt) and attach them +to the line items in the 'cust_tax_exempt_pkg' pseudo-field. + +For accurate calculation of per-customer or per-location taxes, ALL items +appearing on the invoice (and subject to this tax) MUST be passed to this +method together, and NO items from any other invoice should be included. =cut +# future optimization: it would probably suffice to return only the link +# records, and let the consolidation routine build the cust_bill_pkgs + sub taxline_cch { my $self = shift; # this used to accept a hash of options but none of them did anything # so it's been removed. - my $taxables; - - if (ref($_[0]) eq 'ARRAY') { - $taxables = shift; - }else{ - $taxables = [ @_ ]; - #exemptions would be broken in this case - } + my $taxables = shift; + my $classes = shift || []; my $name = $self->taxname; $name = 'Other surcharges' if ($self->passtype == 2); my $amount = 0; - - if ( $self->disabled ) { # we always know how to handle disabled taxes - return { - 'name' => $name, - 'amount' => $amount, - }; - } + + return unless @$taxables; # nothing to do + return if $self->disabled; + return if $self->passflag eq 'N'; # tax can't be passed to the customer + # but should probably still appear on the liability report--create a + # cust_tax_exempt_pkg record for it? + + # in 4.x, the invoice is _already inserted_ before we try to calculate + # tax on it. though it may be a quotation, so be careful. + + my $cust_main; + my $cust_bill = $taxables->[0]->cust_bill; + $cust_main = $cust_bill->cust_main if $cust_bill; my $taxable_charged = 0; my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; } @$taxables; + my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum; + warn "calculating taxes for ". $self->taxnum. " on ". join (",", map { $_->pkgnum } @cust_bill_pkg) if $DEBUG; - if ($self->passflag eq 'N') { - # return "fatal: can't (yet) handle taxes not passed to the customer"; - # until someone needs to track these in freeside - return { - 'name' => $name, - 'amount' => 0, - }; - } - my $maxtype = $self->maxtype || 0; if ($maxtype != 0 && $maxtype != 1 && $maxtype != 14 && $maxtype != 15 @@ -451,54 +461,144 @@ sub taxline_cch { $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' ); } - unless ($self->setuptax =~ /^Y$/i) { - $taxable_charged += $_->setup foreach @cust_bill_pkg; - } - unless ($self->recurtax =~ /^Y$/i) { - $taxable_charged += $_->recur foreach @cust_bill_pkg; - } + my @tax_locations; + my %seen; # locationnum or pkgnum => 1 + my $taxable_cents = 0; my $taxable_units = 0; - unless ($self->recurtax =~ /^Y$/i) { - - if (( $self->unittype || 0 ) == 0) { #access line - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units += $_->units - unless $seen{$_->pkgnum}++; + my $tax_cents = 0; + + while (@$taxables) { + my $cust_bill_pkg = shift @$taxables; + my $class = shift @$classes; + $class = 'all' if !defined($class); + + my %usage_map = map { $_ => $cust_bill_pkg->usage($_) } + $cust_bill_pkg->usage_classes; + my $usage_total = sum( values(%usage_map), 0 ); + + # determine if the item has exemptions that apply to this tax def + my @exemptions = grep { $_->taxnum == $self->taxnum } + @{ $cust_bill_pkg->cust_tax_exempt_pkg }; + + if ( $self->tax > 0 ) { + + my $taxable_charged = 0; + if ($class eq 'all') { + $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur; + } elsif ($class eq 'setup') { + $taxable_charged = $cust_bill_pkg->setup; + } elsif ($class eq 'recur') { + $taxable_charged = $cust_bill_pkg->recur - $usage_total; + } else { + $taxable_charged = $usage_map{$class} || 0; } - } elsif ($self->unittype == 1) { #minute - return $self->_fatal_or_null( 'fee with minute unit type' ); - - } elsif ($self->unittype == 2) { #account + foreach my $ex (@exemptions) { + # the only cases where the exemption doesn't apply: + # if it's a setup exemption and $class is not 'setup' or 'all' + # if it's a recur exemption and $class is 'setup' + if ( ( $ex->exempt_recur and $class eq 'setup' ) + or ( $ex->exempt_setup and $class ne 'setup' and $class ne 'all' ) + ) { + next; + } - my $conf = new FS::Conf; - if ( $conf->exists('tax-pkg_address') ) { - #number of distinct locations - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units++ - unless $seen{$_->cust_pkg->locationnum}++; + $taxable_charged -= $ex->amount; + } + # cust_main_county handles monthly capped exemptions; this doesn't. + # + # $taxable_charged can also be less than zero at this point + # (recur exemption + usage class breakdown); treat that as zero. + next if $taxable_charged <= 0; + + # yeah, some false laziness with cust_main_county + my $this_tax_cents = int(100 * $taxable_charged * $self->tax); + my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, # not a real field + 'locationtaxid' => $self->location, # fundamentally silly + 'taxable_billpkgnum' => $cust_bill_pkg->billpkgnum, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + 'taxclass' => $class, + }); + push @tax_locations, $tax_location; + + $taxable_cents += 100 * $taxable_charged; + $tax_cents += $this_tax_cents; + + } elsif ( $self->fee > 0 ) { + # most CCH taxes are this type, because nearly every county has a 911 + # fee + my $units = 0; + + # since we don't support partial exemptions (except setup/recur), + # if there's an exemption that applies to this package and taxrate, + # don't charge ANY per-unit fees + next if @exemptions; + + # don't apply fees to usage classes (maybe if we ever get per-minute + # fees?) + next unless $class eq 'setup' + or $class eq 'recur' + or $class eq 'all'; + + if ( $self->unittype == 0 ) { + if ( !$seen{$cust_bill_pkg->pkgnum} ) { + # per access line + $units = $cust_bill_pkg->units; + $seen{$cust_bill_pkg->pkgnum} = 1; + } # else it's been seen, leave it at zero units + + } elsif ($self->unittype == 1) { # per minute + # STILL not supported...fortunately these only exist if you happen + # to be in Idaho or Little Rock, Arkansas + # + # though a voip_cdr package could easily report minutes of usage... + return $self->_fatal_or_null( 'fee with minute unit type' ); + + } elsif ( $self->unittype == 2 ) { + + # per account + my $locationnum = $cust_bill_pkg->tax_locationnum; + if (!$locationnum and $cust_main) { + $locationnum = $cust_main->ship_locationnum; } + # the other case is that it's a quotation + + $units = 1 unless $seen{$cust_bill_pkg->tax_locationnum}; + $seen{$cust_bill_pkg->tax_locationnum} = 1; + } else { - $taxable_units = 1; + # Unittype 19 is used for prepaid wireless E911 charges in many states. + # Apparently "per retail purchase", which for us would mean per invoice. + # Unittype 20 is used for some 911 surcharges and I have no idea what + # it means. + return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + my $this_tax_cents = int($units * $self->fee * 100); + my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, + 'locationtaxid' => $self->location, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + }); + push @tax_locations, $tax_location; + + $taxable_units += $units; + $tax_cents += $this_tax_cents; - } else { - return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + } # foreach $cust_bill_pkg - } + # check bracket maxima; throw an error if we've gone over, because + # we don't really implement them - # XXX handle excessrate (use_excessrate) / excessfee / - # taxbase/feebase / taxmax/feemax - # and eventually exemptions - # - # the tax or fee is applied to taxbase or feebase and then - # the excessrate or excess fee is applied to taxmax or feemax - - if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or + if ( ($self->taxmax > 0 and $taxable_cents > $self->taxmax*100 ) or ($self->feemax > 0 and $taxable_units > $self->feemax) ) { # throw an error # (why not just cap taxable_charged/units at the taxmax/feemax? because @@ -507,17 +607,42 @@ sub taxline_cch { return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum ); } - $amount += $taxable_charged * $self->tax; - $amount += $taxable_units * $self->fee; - - warn "calculated taxes as [ $name, $amount ]\n" - if $DEBUG; + # round and distribute + my $total_tax_cents = sprintf('%.0f', + ($taxable_cents * $self->tax) + ($taxable_units * $self->fee * 100) + ); + my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents); + $tax_cents += $extra_cents; + my $i = 0; + foreach (@tax_locations) { # can never require more than a single pass, yes? + my $cents = $_->get('cents'); + if ( $extra_cents > 0 ) { + $cents++; + $extra_cents--; + } + $_->set('amount', sprintf('%.2f', $cents/100)); + } - return { - 'name' => $name, - 'amount' => $amount, - }; + # just transform each CBPTRL record into a tax line item. + # calculate_taxes will consolidate them, but before that happens we have + # to do tax on tax calculation. + my @tax_items; + foreach (@tax_locations) { + next if $_->amount == 0; + my $tax_item = FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'recur' => 0, + 'setup' => $_->amount, + 'sdate' => '', # $_->sdate? + 'edate' => '', + 'itemdesc' => $name, + 'cust_bill_pkg_tax_rate_location' => [ $_ ], + }); + $_->set('tax_cust_bill_pkg' => $tax_item); + push @tax_items, $tax_item; + } + return @tax_items; } sub _fatal_or_null { diff --git a/FS/MANIFEST b/FS/MANIFEST index ca532ee0c..575184ced 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -842,3 +842,6 @@ FS/quotation_pkg_tax.pm t/quotation_pkg_tax.t FS/h_svc_circuit.pm FS/h_svc_circuit.t +FS/FeeOrigin_Mixin.pm +FS/cust_pkg_reason_fee.pm +t/cust_pkg_reason_fee.t diff --git a/FS/t/cust_pkg_reason_fee.t b/FS/t/cust_pkg_reason_fee.t new file mode 100644 index 000000000..96cb79a7a --- /dev/null +++ b/FS/t/cust_pkg_reason_fee.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_pkg_reason_fee; +$loaded=1; +print "ok 1\n"; diff --git a/Makefile b/Makefile index 67bf83c09..99e3dbc47 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,10 @@ DIST_CONF = ${FREESIDE_CONF}/default_conf #Apache 2.4 (Debian 8.x) APACHE_VERSION=2.4 -#deb +#deb (-7 and upgrades) FREESIDE_DOCUMENT_ROOT = /var/www/freeside +#deb (new installs of 8+) +#FREESIDE_DOCUMENT_ROOT = /var/www/html/freeside #redhat, fedora, mandrake #FREESIDE_DOCUMENT_ROOT = /var/www/html/freeside #freebsd diff --git a/bin/freeside_cacti.php b/bin/freeside_cacti.php index 22fb0f0f4..0a9ee9c1c 100755 --- a/bin/freeside_cacti.php +++ b/bin/freeside_cacti.php @@ -32,15 +32,15 @@ if (!isset($_SERVER["argv"][0]) || isset($_SERVER['REQUEST_METHOD']) || isset($ $no_http_headers = true; /* -Currently, only drop-device is actually being used by Freeside integration, +Currently, only drop-device and get-graphs is actually being used by Freeside integration, but keeping commented out code for potential future development. */ include(dirname(__FILE__)."/../site/include/global.php"); include_once($config["base_path"]."/lib/api_device.php"); +include_once($config["base_path"]."/lib/api_automation_tools.php"); /* -include_once($config["base_path"]."/lib/api_automation_tools.php"); include_once($config["base_path"]."/lib/api_data_source.php"); include_once($config["base_path"]."/lib/api_graph.php"); include_once($config["base_path"]."/lib/functions.php"); @@ -57,6 +57,9 @@ if (sizeof($parms)) { foreach($parms as $parameter) { @list($arg, $value) = @explode("=", $parameter); switch ($arg) { + case "--get-graphs": + $action = 'get-graphs'; + break; case "--drop-device": $action = 'drop-device'; break; @@ -94,6 +97,9 @@ if (sizeof($parms)) { /* Now take an action */ switch ($action) { +case "get-graphs": + displayHostGraphs(host_id($ip),TRUE); + break; case "drop-device": $host_id = host_id($ip); /* diff --git a/conf/log_sent_mail b/conf/log_sent_mail new file mode 100644 index 000000000..e69de29bb diff --git a/fs_selfservice/FS-SelfService/SelfService.pm b/fs_selfservice/FS-SelfService/SelfService.pm index ff05c84ff..12ffbb0f2 100644 --- a/fs_selfservice/FS-SelfService/SelfService.pm +++ b/fs_selfservice/FS-SelfService/SelfService.pm @@ -1028,6 +1028,10 @@ Existing customer package. New package to order (see L). +=item quantity + +Quantity for this package order (default 1). + =back Returns a hash reference with the following keys: diff --git a/httemplate/browse/reason.html b/httemplate/browse/reason.html index 5bb6a3e0c..8af88a950 100644 --- a/httemplate/browse/reason.html +++ b/httemplate/browse/reason.html @@ -65,7 +65,7 @@ my $align = 'rll'; if ( $class eq 'S' ) { push @header, 'Credit unused service', - 'Unsuspension fee', + 'Suspension fee', ; push @fields, sub { @@ -78,17 +78,29 @@ if ( $class eq 'S' ) { }, sub { my $reason = shift; - my $pkgpart = $reason->unsuspend_pkgpart or return ''; - my $part_pkg = FS::part_pkg->by_key($pkgpart) or return ''; - my $text = $part_pkg->pkg_comment; - my $href = $p."edit/part_pkg.cgi?$pkgpart"; - $text = qq!! . encode_entities($text) . "". - ""; - if ( $reason->unsuspend_hold ) { - $text .= ' (on next bill)' + my $feepart = $reason->feepart; + my ($href, $text, $detail); + if ( $feepart ) { + my $part_fee = FS::part_fee->by_key($feepart) or return ''; + $text = $part_fee->itemdesc . ': ' . $part_fee->explanation; + $detail = $reason->fee_on_unsuspend ? 'unsuspension' : 'suspension'; + if ( $reason->fee_hold ) { + $detail = "next bill after $detail"; + } + $detail = "(on $detail)"; + $href = $p."edit/part_fee.html?$feepart"; } else { - $text .= ' (immediately)' + my $pkgpart = $reason->unsuspend_pkgpart; + my $part_pkg = FS::part_pkg->by_key($pkgpart) or return ''; + $text = $part_pkg->pkg_comment; + $href = $p."edit/part_pkg.cgi?$pkgpart"; + $detail = $reason->unsuspend_hold ? + '(on next bill after unsuspension)' : '(on unsuspension)'; } + return '' unless length($text); + + $text = qq!! . encode_entities($text) . " ". + "$detail"; $text .= ''; } ; diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html index 97976dfef..156910f51 100644 --- a/httemplate/edit/payment_gateway.html +++ b/httemplate/edit/payment_gateway.html @@ -103,6 +103,7 @@ my %modules = ( 'KeyBank', 'Paymentech', 'TD_EFT', + 'BillBuddy', ], ); diff --git a/httemplate/edit/reason.html b/httemplate/edit/reason.html index 3e6645ec8..30168d551 100644 --- a/httemplate/edit/reason.html +++ b/httemplate/edit/reason.html @@ -13,9 +13,12 @@ 'reason' => $classname . ' Reason', 'disabled' => 'Disabled', 'class' => '', - 'unsuspend_pkgpart' => 'Unsuspension fee', - 'unsuspend_hold' => 'Delay until next bill', + 'feepart' => 'Charge a suspension fee', + 'fee_on_unsuspend' => 'When a package is', + 'fee_hold' => 'Delay fee until next bill', 'unused_credit' => 'Credit unused portion of service', + 'unsuspend_pkgpart' => 'Order an unsuspension package', + 'unsuspend_hold' => 'Delay package until next bill', }, 'fields' => \@fields, &> @@ -64,6 +67,28 @@ my @fields = ( if ( $class eq 'S' ) { push @fields, + { 'field' => 'unused_credit', + 'type' => 'checkbox', + 'value' => 'Y', + }, + { 'type' => 'tablebreak-tr-title' }, + { 'field' => 'feepart', + 'type' => 'select-table', + 'table' => 'part_fee', + 'hashref' => { disabled => '' }, + 'name_col' => 'itemdesc', + 'value_col' => 'feepart', + 'empty_label' => 'none', + }, + { 'field' => 'fee_on_unsuspend', + 'type' => 'select', + 'options' => [ '', 'Y' ], + 'labels' => { '' => 'suspended', 'Y' => 'unsuspended' }, + }, + { 'field' => 'fee_hold', + 'type' => 'checkbox', + 'value' => 'Y', + }, { 'field' => 'unsuspend_pkgpart', 'type' => 'select-part_pkg', 'hashref' => { 'disabled' => '', @@ -73,10 +98,6 @@ if ( $class eq 'S' ) { 'type' => 'checkbox', 'value' => 'Y', }, - { 'field' => 'unused_credit', - 'type' => 'checkbox', - 'value' => 'Y', - }, ; } diff --git a/httemplate/elements/tr-select-reason.html b/httemplate/elements/tr-select-reason.html index 356597553..125874694 100755 --- a/httemplate/elements/tr-select-reason.html +++ b/httemplate/elements/tr-select-reason.html @@ -35,13 +35,17 @@ Example: % # - no redundant checking of ACLs or parameters % # - form fields are grouped for easy management % # - use the standard select-table widget instead of ad hoc crap +<& /elements/xmlhttp.html, + url => $p . 'misc/xmlhttp-reason-hint.html', + subs => [ 'get_hint' ], +&> + +% } else { +% if ($error) { + +

<% $error %>

+ +% } else { + +<% slurp($htmlfile) %> + +% } +% } + +<%init> +use File::Slurp qw( slurp ); + +my $svcnum = $cgi->param('svcnum') or die 'Illegal svcnum'; +my $load = $cgi->param('load'); +my $graphnum = $cgi->param('graphnum'); + +my $htmlfile = $FS::UID::cache_dir + . '/cacti-graphs/' + . 'svc_' + . $svcnum; +$htmlfile .= '_graph_' . $graphnum + if $graphnum; +$htmlfile .= '.html'; + +my $error = (-e $htmlfile) ? '' : 'File not found'; + + diff --git a/httemplate/misc/process/cacti_graphs.cgi b/httemplate/misc/process/cacti_graphs.cgi new file mode 100644 index 000000000..160b1ad85 --- /dev/null +++ b/httemplate/misc/process/cacti_graphs.cgi @@ -0,0 +1,6 @@ +<% $server->process %> + +<%init> +my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi); + + diff --git a/httemplate/misc/process/elements/reason b/httemplate/misc/process/elements/reason index ae92a7528..f57f11ff5 100644 --- a/httemplate/misc/process/elements/reason +++ b/httemplate/misc/process/elements/reason @@ -8,7 +8,8 @@ my $error; if ($reasonnum == -1) { my $new_reason = FS::reason->new({ map { $_ => scalar( $cgi->param("reasonnum_new_$_") ) } - qw( reason_type reason unsuspend_pkgpart unsuspend_hold unused_credit ) + qw( reason_type reason unsuspend_pkgpart unsuspend_hold unused_credit + feepart fee_on_unsuspend fee_hold ) }); # not sanitizing them here, but check() will do it $error = $new_reason->insert; $reasonnum = $new_reason->reasonnum; diff --git a/httemplate/misc/xmlhttp-reason-hint.html b/httemplate/misc/xmlhttp-reason-hint.html new file mode 100644 index 000000000..5d54788a4 --- /dev/null +++ b/httemplate/misc/xmlhttp-reason-hint.html @@ -0,0 +1,83 @@ +<%doc> +Example: + +<& /elements/xmlhttp.html, + url => $p . 'misc/xmlhttp-reason-hint.html', + subs => [ 'get_hint' ] +&> + + +Currently will provide hints for: +1. suspension events (new-style reconnection fees, notification) +2. unsuspend_pkgpart package info (older reconnection fees) +3. crediting for unused time + +<%init> +my $sub = $cgi->param('sub'); +my ($reasonnum) = $cgi->param('arg'); +# arg is a reasonnum +my $conf = FS::Conf->new; +my $error = ''; +my @hints; +if ( $reasonnum =~ /^\d+$/ ) { + my $reason = FS::reason->by_key($reasonnum); + if ( $reason ) { + # 1. + if ( $reason->feepart ) { # XXX + my $part_fee = FS::part_fee->by_key($reason->feepart); + my $when = ''; + if ( $reason->fee_hold ) { + $when = 'on the next bill after '; + } else { + $when = 'upon '; + } + if ( $reason->fee_on_unsuspend ) { + $when .= 'unsuspension'; + } else { + $when .= 'suspension'; + } + + my $fee_amt = $part_fee->explanation; + push @hints, mt('A fee of [_1] will be charged [_2].', + $fee_amt, $when); + } + # 2. + if ( $reason->unsuspend_pkgpart ) { + my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart); + if ( $part_pkg ) { + if ( $part_pkg->option('setup_fee',1) > 0 and + $part_pkg->option('recur_fee',1) == 0 ) { + # the usual case + push @hints, + mt('A [_1] unsuspension fee will apply.', + ($conf->config('money_char') || '$') . + sprintf('%.2f', $part_pkg->option('setup_fee')) + ); + } else { + # oddball cases--not really supported + push @hints, + mt('An unsuspension package will apply: [_1]', + $part_pkg->price_info + ); + } + } else { #no $part_pkg + push @hints, + 'Unsuspend pkg #'.$reason->unsuspend_pkgpart. + ' not found.'; + } + } + # 3. + if ( $reason->unused_credit ) { + push @hints, mt('The customer will be credited for unused time.'); + } + } else { + warn "reasonnum $reasonnum not found; returning no hints\n"; + } +} else { + warn "reason-hint arg '$reasonnum' not a valid reasonnum\n"; +} + +<% join('
', @hints) %> diff --git a/httemplate/view/svc_broadband.cgi b/httemplate/view/svc_broadband.cgi index 9fe10bd3a..4935a1096 100644 --- a/httemplate/view/svc_broadband.cgi +++ b/httemplate/view/svc_broadband.cgi @@ -72,15 +72,11 @@ sub ip_addr { my $out = $ip_addr; $out .= ' (' . include('/elements/popup_link-ping.html', ip => $ip_addr) . ')' if $ip_addr; - if ($svc->cacti_leaf_id) { - # should only ever be one, but not sure if that is enforced - my ($cacti) = $svc->cust_svc->part_svc->part_export('cacti'); - $out .= ' (svcnum . '">cacti)'; } if ( my $addr_block = $svc->addr_block ) {