Merge branch '20150325-cust_main-CurrentUser' of https://github.com/fozzmoo/Freeside...
authorIvan Kohler <ivan@freeside.biz>
Fri, 3 Apr 2015 18:24:16 +0000 (11:24 -0700)
committerIvan Kohler <ivan@freeside.biz>
Fri, 3 Apr 2015 18:24:16 +0000 (11:24 -0700)
36 files changed:
FS/FS/ClientAPI/MyAccount.pm
FS/FS/FeeOrigin_Mixin.pm [new file with mode: 0644]
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/TaxEngine.pm
FS/FS/TaxEngine/cch.pm
FS/FS/TaxEngine/internal.pm
FS/FS/Template_Mixin.pm
FS/FS/cdr/earthlink.pm
FS/FS/cdr/ispphone.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg.pm
FS/FS/cust_event_fee.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_pkg.pm
FS/FS/cust_pkg_reason_fee.pm [new file with mode: 0644]
FS/FS/part_export/a2billing.pm
FS/FS/part_export/cacti.pm
FS/FS/pay_batch.pm
FS/FS/payment_gateway.pm
FS/FS/reason.pm
FS/FS/tax_rate.pm
FS/MANIFEST
FS/t/cust_pkg_reason_fee.t [new file with mode: 0644]
Makefile
bin/freeside_cacti.php
conf/log_sent_mail [new file with mode: 0644]
fs_selfservice/FS-SelfService/SelfService.pm
httemplate/browse/reason.html
httemplate/edit/payment_gateway.html
httemplate/edit/reason.html
httemplate/elements/tr-select-reason.html
httemplate/misc/cacti_graphs.html [new file with mode: 0644]
httemplate/misc/process/cacti_graphs.cgi [new file with mode: 0644]
httemplate/misc/process/elements/reason
httemplate/misc/xmlhttp-reason-hint.html [new file with mode: 0644]
httemplate/view/svc_broadband.cgi

index 2671afb..471a093 100644 (file)
@@ -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 (file)
index 0000000..8bd9acd
--- /dev/null
@@ -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<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;
+
index 2cabf85..8f7f739 100644 (file)
@@ -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 ) {
index 3cdad43..4bc3598 100644 (file)
@@ -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'       => [],
index a146c54..54e305f 100644 (file)
@@ -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<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
 
@@ -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<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} } ) {
@@ -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
index 6bad69e..4e6dbaf 100644 (file)
@@ -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<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
@@ -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 {
index 60f7aad..99535ad 100644 (file)
@@ -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;
   }
 }
index 59899cf..2479ef6 100644 (file)
@@ -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;
     }
index 0421ef9..da0d545 100644 (file)
@@ -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 (file)
index 0000000..49d1b07
--- /dev/null
@@ -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;
+
index 7257a9b..d0cec90 100644 (file)
@@ -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<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.
@@ -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
index e88dcc4..375a533 100644 (file)
@@ -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<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
 
@@ -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<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 = @_;
@@ -167,13 +160,45 @@ sub by_cust {
   })
 }
 
-                  
+=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
 
index 87499a9..8f62348 100644 (file)
@@ -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<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 {
@@ -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;
     }
 
index bafbb58..be5bdea 100644 (file)
@@ -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<FS::cust_main>)
 
 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 (file)
index 0000000..1155c15
--- /dev/null
@@ -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<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;
+
index 0821a34..15410ae 100644 (file)
@@ -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)
index 6877c8f..1f5f64c 100644 (file)
@@ -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(<!-- 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;
@@ -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
index 449ea22..a7628f6 100644 (file)
@@ -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;
   '';
 }
index 95b7c40..afae266 100644 (file)
@@ -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)
index 9c34dd9..6f4bf62 100644 (file)
@@ -50,7 +50,7 @@ FS::Record.  The following fields are currently supported:
 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 
@@ -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
index 0047f9d..8579020 100644 (file)
@@ -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<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
@@ -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 {
index ca532ee..575184c 100644 (file)
@@ -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 (file)
index 0000000..96cb79a
--- /dev/null
@@ -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";
index 67bf83c..99e3dbc 100644 (file)
--- 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
index 22fb0f0..0a9ee9c 100755 (executable)
@@ -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 (file)
index 0000000..e69de29
index ff05c84..12ffbb0 100644 (file)
@@ -1028,6 +1028,10 @@ Existing customer package.
 
 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:
index 5bb6a3e..8af88a9 100644 (file)
@@ -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!<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>';
     }
   ;
index 97976df..156910f 100644 (file)
@@ -103,6 +103,7 @@ my %modules = (
     'KeyBank',
     'Paymentech',
     'TD_EFT',
+    'BillBuddy',
   ],
 );
 
index 3e6645e..30168d5 100644 (file)
                 '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',
-    }, 
   ;
 }
 
index 3565975..1258746 100755 (executable)
@@ -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' ],
+&>
 <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 %>);
@@ -123,24 +127,45 @@ Example:
         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>
@@ -188,43 +213,6 @@ my @reasons = qsearch({
   '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' );
diff --git a/httemplate/misc/cacti_graphs.html b/httemplate/misc/cacti_graphs.html
new file mode 100644 (file)
index 0000000..9cc5e24
--- /dev/null
@@ -0,0 +1,53 @@
+<% 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>
+
diff --git a/httemplate/misc/process/cacti_graphs.cgi b/httemplate/misc/process/cacti_graphs.cgi
new file mode 100644 (file)
index 0000000..160b1ad
--- /dev/null
@@ -0,0 +1,6 @@
+<% $server->process %>
+
+<%init>
+my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi);
+</%init>
+
index ae92a75..f57f11f 100644 (file)
@@ -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 (file)
index 0000000..5d54788
--- /dev/null
@@ -0,0 +1,83 @@
+<%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) %>
index 9fe10bd..4935a10 100644 (file)
@@ -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 .= ' (<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 ) {