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 };
--- /dev/null
+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<FS::cust_bill_pkg> 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<FS::part_fee>).
+
+=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</by_cust> 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<FS::cust_bill> 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<FS::cust_pkg> 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</cust_bill>; 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<FS::cust_event_fee>, L<FS::cust_pkg_reason_fee>, L<FS::cust_bill_pkg>,
+L<FS::part_fee>
+
+=cut
+
+1;
+
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 ) {
'amount', @money_type, '', '',
'currency', 'char', 'NULL', 3, '', '',
'taxable_billpkgnum', 'int', 'NULL', '', '', '',
+ 'taxclass', 'varchar', 'NULL', 10, '', '',
],
'primary_key' => 'billpkgtaxratelocationnum',
'unique' => [],
'download', @date_type, '', '',
'upload', @date_type, '', '',
'title', 'varchar', 'NULL',255, '', '',
+ 'processor_id', 'varchar', 'NULL',255, '', '',
],
'primary_key' => 'batchnum',
'unique' => [],
],
},
+ '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', '', '', '', '',
#'custnum', 'int', '', '', '', ''
'billpkgnum', 'int', '', '', '', '',
'taxnum', 'int', '', '', '', '',
+ 'taxtype', 'varchar', 'NULL', $char_d, '', '',
'year', 'int', 'NULL', '', '', '',
'month', 'int', 'NULL', '', '', '',
'creditbillpkgnum', 'int', 'NULL', '', '', '',
'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',
},
#'custnum', 'int', '', '', '', ''
'billpkgnum', 'int', '', '', '', '',
'taxnum', 'int', '', '', '', '',
+ 'taxtype', 'varchar', 'NULL', $char_d, '', '',
'year', 'int', 'NULL', '', '', '',
'month', 'int', 'NULL', '', '', '',
'creditbillpkgnum', 'int', 'NULL', '', '', '',
'unique' => [],
'index' => [ [ 'taxnum', 'year', 'month' ],
[ 'billpkgnum' ],
- [ 'taxnum' ],
+ [ 'taxnum', 'taxtype' ],
[ 'creditbillpkgnum' ],
],
'foreign_keys' => [
'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' ] ],
'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' => [],
=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<add_sale> on the
+2. Each time a sale item is added to the invoice, call L</add_sale> 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<collect> on each of the billed customers.
+ L<FS::cust_main::Billing/collect> on each of the billed customers.
- If not (the internal tax system, CCH):
-3. After adding all sale items, call C<calculate_taxes> on the TaxEngine to
+5. After adding all sale items, call L</calculate_taxes> 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
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;
}
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<FS::cust_bill_pkg> objects to add to the invoice. There is a base
-implementation of this, which calls the C<taxline> method to calculate
-each individual tax.
+L<FS::cust_bill_pkg> objects to add to the invoice. The base implementation
+is to call L</make_taxlines> to produce a list of "raw" tax line items,
+then L</consolidate_taxlines> 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} } ) {
# 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;
$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
push @tax_line_items, $tax_cust_bill_pkg;
}
- \@tax_line_items;
+ @tax_line_items;
}
=head1 CLASS METHODS
=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.
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<FS::cust_bill_pkg> object) and adds it to three internal
+data structures:
- push @{ $self->{items} }, $cust_bill_pkg;
+- C<items>, an arrayref of all items on this invoice.
+- C<taxes>, a hashref of taxnum => arrayref containing the items that are
+ taxable under that tax definition.
+- C<taxclass>, a hashref of taxnum => arrayref containing the tax class
+ names parallel to the C<taxes> array for the same tax.
- my $conf = FS::Conf->new;
+The item will appear on C<taxes> 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<add_sale> 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
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 {
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;
$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;
}
}
# 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 ) {
# ($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', {
$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;
}
my $datetime = $date. " ". $time;
$cdr->set('startdate', $datetime );
}, #time
+ skip(1), #TollFreeNumber
sub { my($cdr, $src) = @_;
$src =~ s/\D//g;
$cdr->set('src', $src);
--- /dev/null
+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;
+
}
}
- 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,
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', '');
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 ) {
} # 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";
}
}
return;
}
+=item set_exemptions TAXOBJECT, OPTIONS
+
+Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or
+L<FS::tax_rate> 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<FS::cust_bill>) for this invoice line item.
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
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
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
An FS::cust_event_fee object links a billing event that charged a fee
(an L<FS::cust_event>) to the resulting invoice line item (an
-L<FS::cust_bill_pkg> object). FS::cust_event_fee inherits from FS::Record.
-The following fields are currently supported:
+L<FS::cust_bill_pkg> object). FS::cust_event_fee inherits from FS::Record
+and FS::FeeOrigin_Mixin. The following fields are currently supported:
=over 4
=cut
-# the check method should currently be supplied - FS::Record contains some
-# data checking routines
-
sub check {
my $self = shift;
=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<FS::FeeOrigin_Mixin/by_cust>. 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 = @_;
})
}
-
+=item cust_bill
+
+See L<FS::FeeOrigin_Mixin/cust_bill>. 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<FS::FeeOrigin_Mixin/cust_bill>. 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<FS::cust_event>, L<FS::part_fee>, L<FS::Record>
+L<FS::cust_event>, L<FS::FeeOrigin_Mixin>, L<FS::Record>
=cut
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;
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;
# 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
# 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.
}
# 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;
}
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<set_exemptions> method of L<FS::cust_bill_pkg>) and
+attach them. This is the only place C<set_exemptions> is called in normal
+invoice processing.
+
=cut
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
- }
}
}
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;
}
$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 ] );
if $error;
}
+ my $cust_pkg_reason;
if ( $options{'reason'} ) {
$error = $self->insert_reason( 'reason' => $options{'reason'},
'action' => $date ? 'adjourn' : '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
}
}
+ # 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'} ) {
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 ) {
=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<FS::cust_main>)
PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
--- /dev/null
+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<FS::cust_pkg_reason> 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<FS::part_fee>).
+
+=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<FS::FeeOrigin_Mixin/by_cust>.
+
+=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<FS::FeeOrigin_Mixin>, L<FS::cust_pkg_reason>, L<part_fee>
+
+=cut
+
+1;
+
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'),
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,
} 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)
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 ';
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',
# },
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
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(<!-- UPDATED ) . $now . qq( -->\n)
+ . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>' . "\n"
+ . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>) . "\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(<H3>) . $$graph[1] . q(</H3>) . "\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, '<P>No detail graphs to display for this graph</P>')
+ if $nodetail;
+ }
+ $job->update_statustext(50 + ($i / $#graphs) * 50);
+ }
+ append_file($svchtml,'<P>No graphs to display for this service</P>')
+ if $nographs;
+
+ $job->update_statustext(100);
+ return '';
+}
+
+sub img_tag {
+ my $somefile = shift;
+ return q(<IMG SRC="data:image/png;base64,)
+ . encode_base64(slurp($somefile,binmode=>':raw'))
+ . qq(" STYLE="margin-bottom: 1em;"><BR>\n);
+}
+
+sub anchor_tag {
+ my ($svcnum, $graphnum, $contents) = @_;
+ return q(<A HREF="?svcnum=)
+ . $svcnum
+ . q(&graphnum=)
+ . $graphnum
+ . q(">)
+ . $contents
+ . q(</A>);
+}
+
+#this gets used by everything else
#fake false laziness, other ssh_cmds handle error/output differently
sub ssh_cmd {
use Net::OpenSSH;
(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
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
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;
);
$processor->submit($batch);
+ if ($batch->processor_id) {
+ $self->set('processor_id',$batch->processor_id);
+ $self->replace;
+ }
+
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
'';
}
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)
L<FS::part_pkg>) 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
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
|| $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($_ => '');
}
}
$reason;
}
-
=head1 BUGS
=head1 SEE ALSO
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;
$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<FS::cust_bill_pkg> objects representing taxable line
+items, and an arrayref of charge classes ('setup', 'recur', '' for
+unclassified usage, or an L<FS::usage_class> number). Calculates the tax on
+each item under this tax definition and returns a list of new
+L<FS::cust_bill_pkg> objects for the taxes charged. Each returned object
+will have a pseudo-field, "cust_bill_pkg_tax_rate_location", containing a
+single L<FS::cust_bill_pkg_tax_rate_location> 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
$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
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 {
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
--- /dev/null
+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";
#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
$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");
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;
/* Now take an action */
switch ($action) {
+case "get-graphs":
+ displayHostGraphs(host_id($ip),TRUE);
+ break;
case "drop-device":
$host_id = host_id($ip);
/*
New package to order (see L<FS::part_pkg>).
+=item quantity
+
+Quantity for this package order (default 1).
+
=back
Returns a hash reference with the following keys:
if ( $class eq 'S' ) {
push @header,
'Credit unused service',
- 'Unsuspension fee',
+ 'Suspension fee',
;
push @fields,
sub {
},
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!<A HREF="$href">! . encode_entities($text) . "</A>".
- "<FONT SIZE=-1>";
- 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!<A HREF="$href">! . encode_entities($text) . "</A> ".
+ "<FONT SIZE=-1>$detail</FONT>";
$text .= '</FONT>';
}
;
'KeyBank',
'Paymentech',
'TD_EFT',
+ 'BillBuddy',
],
);
'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,
&>
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' => '',
'type' => 'checkbox',
'value' => 'Y',
},
- { 'field' => 'unused_credit',
- 'type' => 'checkbox',
- 'value' => 'Y',
- },
;
}
% # - 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' ],
+&>
<SCRIPT TYPE="text/javascript">
function <% $id %>_changed() {
- var hints = <% encode_json(\%all_hints) %>;
var select_reason = document.getElementById('<% $id %>');
- document.getElementById('<% $id %>_hint').innerHTML =
- hints[select_reason.value] || '';
+ get_hint(select_reason.value, function(stuff) {
+ document.getElementById('<% $id %>_hint').innerHTML = stuff || '';
+ });
// toggle submit button state
var submit_button = document.getElementById(<% $opt{control_button} |js_string %>);
field => $id.'_new_unused_credit',
value => 'Y'
&>
- <& tr-select-part_pkg.html,
- label => 'Charge this fee when unsuspending',
- field => $id.'_new_unsuspend_pkgpart',
- hashref => { disabled => '', freq => '0' },
+ <& tr-select-table.html,
+ label => 'Charge a suspension fee',
+ field => $id.'_new_feepart',
+ table => 'part_fee',
+ hashref => { disabled => '' },
+ name_col => 'itemdesc',
+ value_col => 'feepart',
empty_label => 'none',
&>
+ <& tr-select.html,
+ label => 'When this package is',
+ field => $id.'_new_fee_on_unsuspend',
+ options => [ '', 'Y' ],
+ labels => { '' => 'suspended', 'Y' => 'unsuspended' },
+ &>
<& tr-checkbox.html,
- label => 'Hold unsuspension fee until the next bill',
- field => $id.'_new_unsuspend_hold',
- value => 'Y',
+ label => 'Delay fee until the next bill',
+ field => $id.'_new_fee_hold',
+ value => 'Y',
&>
+%# deprecated, but still accessible through the "Suspend Reasons" UI
+%# <& tr-select-part_pkg.html,
+%# label => 'Charge this fee when unsuspending',
+%# field => $id.'_new_unsuspend_pkgpart',
+%# hashref => { disabled => '', freq => '0' },
+%# empty_label => 'none',
+%# &>
+%# <& tr-checkbox.html,
+%# label => 'Hold unsuspension fee until the next bill',
+%# field => $id.'_new_unsuspend_hold',
+%# value => 'Y',
+%# &>
% }
</table>
</td>
</tr>
% } # if the current user can add a reason
-% # container for hints
+% # container for hints (hints themselves come from xmlhttp-reason-hint)
<TR>
<TD COLSPAN=2 ALIGN="center" id="<% $id %>_hint" style="font-size:small">
</TD>
'order_by' => ' ORDER BY type, reason',
});
-my %all_hints;
-if ( $class eq 'S' ) {
- my $conf = FS::Conf->new;
- %all_hints = ( 0 => '', -1 => '' );
- foreach my $reason (@reasons) {
- my @hints;
- 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,
- '<FONT COLOR="#ff0000">Unsuspend pkg #'.$reason->unsuspend_pkgpart.
- ' not found.</FONT>';
- }
- }
- if ( $reason->unused_credit ) {
- push @hints, mt('The customer will be credited for unused time.');
- }
- $all_hints{ $reason->reasonnum } = join('<BR>', @hints);
- }
-}
-
my @post_options;
if ( $curuser->access_right($add_access_right) ) {
@post_options = ( -1 => 'Add new reason' );
--- /dev/null
+<% include( '/elements/header.html', 'Cacti Graphs' ) %>
+
+% if ($load) {
+
+<FORM NAME="CactiGraphForm" ID="CactiGraphForm" style="margin-top: 0">
+<INPUT TYPE="hidden" NAME="svcnum" VALUE="<% $svcnum %>">
+</FORM>
+<% include( '/elements/progress-init.html',
+ 'CactiGraphForm',
+ [ 'svcnum' ],
+ $p.'misc/process/cacti_graphs.cgi',
+ { url => 'javascript:window.location.replace("'.popurl(2).'misc/cacti_graphs.html?svcnum='.$svcnum.'")' },
+) %>
+<!--
+ note we use window.location.replace for the callback url above
+ so that this page gets removed from browser history after processing
+ so that process() doesn't get triggered by the back button
+-->
+<P>Loading graphs, please wait...</P>
+<SCRIPT TYPE="text/javascript">
+process();
+</SCRIPT>
+
+% } else {
+% if ($error) {
+
+<P><% $error %></P>
+
+% } 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';
+</%init>
+
--- /dev/null
+<% $server->process %>
+
+<%init>
+my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi);
+</%init>
+
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;
--- /dev/null
+<%doc>
+Example:
+
+<& /elements/xmlhttp.html,
+ url => $p . 'misc/xmlhttp-reason-hint.html',
+ subs => [ 'get_hint' ]
+&>
+<script>
+var reasonnum = 101;
+get_hint( reasonnum, function(stuff) { alert(stuff); } )
+</script>
+
+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
+</%doc>
+<%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,
+ '<FONT COLOR="#ff0000">Unsuspend pkg #'.$reason->unsuspend_pkgpart.
+ ' not found.</FONT>';
+ }
+ }
+ # 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";
+}
+</%init>
+<% join('<BR>', @hints) %>
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 .= ' (<A HREF="'
- . $cacti->option('base_url')
- . 'graph_view.php?action=tree&tree_id='
- . $cacti->option('tree_id')
- . '&leaf_id='
- . $svc->cacti_leaf_id
+ if ($svc->cust_svc->part_svc->part_export('cacti')) {
+ $out .= ' (<A HREF="'
+ . popurl(2)
+ . 'misc/cacti_graphs.html?load=1&svcnum='
+ . $svc->svcnum
. '">cacti</A>)';
}
if ( my $addr_block = $svc->addr_block ) {