Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Tue, 13 Dec 2016 20:40:15 +0000 (12:40 -0800)
committerIvan Kohler <ivan@freeside.biz>
Tue, 13 Dec 2016 20:40:15 +0000 (12:40 -0800)
FS/FS/Schema.pm
FS/FS/TaxEngine/billsoft.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_pkg.pm
FS/FS/part_pkg.pm
FS/FS/part_pkg_taxproduct.pm
FS/t/suite/14-tokenization_refund.t [new file with mode: 0755]
httemplate/browse/part_pkg.cgi
httemplate/edit/part_pkg.cgi
httemplate/elements/tr-part_pkg-taxproducts.html

index f8b82f4..0e41b1a 100644 (file)
@@ -3248,6 +3248,7 @@ sub tables_hashref {
         'adjourn_months', 'int',    'NULL', '', '', '',
         'contract_end_months','int','NULL', '', '', '',
         'change_to_pkgpart', 'int', 'NULL', '', '', '',
+        'units_taxproductnum','int','NULL', '', '', '',
       ],
       'primary_key'  => 'pkgpart',
       'unique'       => [],
@@ -3265,6 +3266,10 @@ sub tables_hashref {
                           { columns    => [ 'taxproductnum' ],
                             table      => 'part_pkg_taxproduct',
                           },
+                          { columns    => [ 'units_taxproductnum' ],
+                            table      => 'part_pkg_taxproduct',
+                            references => [ 'taxproductnum' ],
+                          },
                           { columns    => [ 'agentnum' ],
                             table      => 'agent',
                           },
index 69717a2..9147f5c 100644 (file)
@@ -188,8 +188,7 @@ sub create_batch {
     # cache some things
     my (%cust_pkg, %part_pkg, %cust_location, %classname);
     # keys are transaction codes (the first part of the taxproduct string)
-    # and then locationnums; for per-location taxes
-    my %sales;
+    my %all_tcodes;
 
     my @options = $self->conf->config('billsoft-taxconfig');
     
@@ -239,8 +238,7 @@ sub create_batch {
 
         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $classnum)
           or next;
-        my $tcode = substr($taxproduct, 0, 6);
-        my $scode = substr($taxproduct, 6, 6);
+        my ($tcode, $scode) = split(':', $taxproduct);
 
         # For CDRs, use the call termination site rather than setting
         # Termination fields to the service address.
@@ -259,9 +257,6 @@ sub create_batch {
 
       } # while $cdr = $cdr_search->fetch
       
-      my $recur_tcode;
-      # now write lines for the non-CDR portion of the charges
-
       my $locationnum = $cust_pkg->locationnum;
 
       # use termination address for the service location
@@ -284,13 +279,10 @@ sub create_batch {
         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $_);
         next unless $taxproduct;
 
-        my $tcode = substr($taxproduct, 0, 6);
-        my $scode = substr($taxproduct, 6, 6);
-        $sales{$tcode} ||= 0;
-        $recur_tcode = $tcode if $_ eq 'recur';
+        my ($tcode, $scode) = split(':', $taxproduct);
+        $all_tcodes{$tcode} ||= 1;
 
         my $price = $cust_bill_pkg->get($_);
-        $sales{$tcode} += $price;
 
         $price -= $usage_total if $_ eq 'recur';
 
@@ -305,10 +297,9 @@ sub create_batch {
 
       } # foreach (setup, recur)
 
-      # S-code 21: taxes based on number of lines (E911, mostly)
-      # voip_cdr and voip_inbound packages know how to report this.  Not all 
-      # T-codes are eligible for this; only report it if the /21 taxproduct
-      # exists.
+      # taxes based on number of lines (E911, mostly)
+      # mostly S-code 21 but can be others, as they want to know about
+      # Centrex trunks, PBX extensions, etc.
       #
       # (note: the nomenclature of "service" and "transaction" codes is 
       # backward from the way most people would use the terms.  you'd think
@@ -318,25 +309,18 @@ sub create_batch {
       # to avoid confusion.)
 
       # XXX cache me
-      # XXX this isn't precisely correct. Local exchange service on
-      # high-capacity trunks, Centrex, and PBX trunks are supposed to be
-      # reported as three separate implicit transactions: number of trunks,
-      # of outbound channels, of extensions.
-      # This is also true for VoIP PBX trunks. Come back to this.
-      if ( $recur_tcode ) {
-        my $lines_taxproduct = FS::part_pkg_taxproduct->count(
-          'data_vendor = \'billsoft\' and taxproduct = ?',
-          sprintf('%06d%06d', $recur_tcode, 21)
-        );
+      if ( my $lines_taxproduct = $part_pkg->units_taxproduct ) {
         my $lines = $cust_bill_pkg->units;
-
-        if ( $lines_taxproduct and $lines ) {
+        my $taxproduct = $lines_taxproduct->taxproduct;
+        my ($tcode, $scode) = split(':', $taxproduct);
+        $all_tcodes{$tcode} ||= 1;
+        if ( $lines ) {
           $csv->print_hr($fh, {
             %pkg_properties,
             %termination,
             RequestType       => 'CalcTaxes',
-            TransactionType   => $recur_tcode,
-            ServiceType       => 21,
+            TransactionType   => $tcode,
+            ServiceType       => $scode,
             Charge            => 0,
             Lines             => $lines,
           } );
@@ -345,12 +329,16 @@ sub create_batch {
 
     } # foreach my $cust_bill_pkg
 
-    foreach my $tcode (keys %sales) {
+    foreach my $tcode (keys %all_tcodes) {
 
-      # S-code 43: per-invoice tax (apparently this is a thing)
+      # S-code 43: per-invoice tax
+      # XXX not exactly correct; there's "Invoice Bundle" (7:94) and
+      # "Centrex Invoice" (7:623). Local Exchange service would benefit from
+      # more high-level selection of the tax properties. (Infer from the FCC
+      # reporting options?)
       my $invoice_taxproduct = FS::part_pkg_taxproduct->count(
         'data_vendor = \'billsoft\' and taxproduct = ?',
-        sprintf('%06d%06d', $tcode, 43)
+        $tcode . ':43'
       );
       if ( $invoice_taxproduct ) {
         $csv->print_hr($fh, {
index 4821ce5..6932647 100644 (file)
@@ -544,14 +544,19 @@ sub bill {
 
     foreach my $part_pkg ( @part_pkg ) {
 
-      $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
+      my $this_cust_pkg = $cust_pkg;
+      # for add-on packages, copy the object to avoid leaking changes back to
+      # the caller if pkg_list is in use; see RT#73607
+      if ( $part_pkg->get('pkgpart') != $real_pkgpart ) {
+        $this_cust_pkg = FS::cust_pkg->new({ %hash });
+      }
 
       my $pass = '';
-      if ( $cust_pkg->separate_bill ) {
+      if ( $this_cust_pkg->separate_bill ) {
         # if no_auto is also set, that's fine. we just need to not have
         # invoices that are both auto and no_auto, and since the package
         # gets an invoice all to itself, it will only be one or the other.
-        $pass = $cust_pkg->pkgnum;
+        $pass = $this_cust_pkg->pkgnum;
         if (!exists $cust_bill_pkg{$pass}) { # it may not exist yet
           push @passes, $pass;
           $total_setup{$pass} = do { my $z = 0; \$z };
@@ -565,17 +570,17 @@ sub bill {
                                 );
           $cust_bill_pkg{$pass} = [];
         }
-      } elsif ( ($cust_pkg->no_auto || $part_pkg->no_auto) ) {
+      } elsif ( ($this_cust_pkg->no_auto || $part_pkg->no_auto) ) {
         $pass = 'no_auto';
       }
 
-      my $next_bill = $cust_pkg->getfield('bill') || 0;
+      my $next_bill = $this_cust_pkg->getfield('bill') || 0;
       my $error;
       # let this run once if this is the last bill upon cancellation
       while ( $next_bill <= $cmp_time or $options{cancel} ) {
         $error =
           $self->_make_lines( 'part_pkg'            => $part_pkg,
-                              'cust_pkg'            => $cust_pkg,
+                              'cust_pkg'            => $this_cust_pkg,
                               'precommit_hooks'     => \@precommit_hooks,
                               'line_items'          => $cust_bill_pkg{$pass},
                               'setup'               => $total_setup{$pass},
@@ -590,12 +595,12 @@ sub bill {
         last if $error;
 
         # or if we're not incrementing the bill date.
-        last if ($cust_pkg->getfield('bill') || 0) == $next_bill;
+        last if ($this_cust_pkg->getfield('bill') || 0) == $next_bill;
 
         # or if we're letting it run only once
         last if $options{cancel};
 
-        $next_bill = $cust_pkg->getfield('bill') || 0;
+        $next_bill = $this_cust_pkg->getfield('bill') || 0;
 
         #stop if -o was passed to freeside-daily
         last if $options{'one_recur'};
index 35293f0..59792e7 100644 (file)
@@ -1454,9 +1454,10 @@ sub realtime_refund_bop {
       ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
     }
 
+    my $payment_gateway;
     if ( $gatewaynum ) { #gateway for the payment to be refunded
 
-      my $payment_gateway =
+      $payment_gateway =
         qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
       die "payment gateway $gatewaynum not found"
         unless $payment_gateway;
@@ -1470,7 +1471,7 @@ sub realtime_refund_bop {
     } else { #try the default gateway
 
       my $conf_processor;
-      my $payment_gateway =
+      $payment_gateway =
         $self->agent->payment_gateway('method' => $options{method});
 
       ( $conf_processor, $login, $password, $namespace ) =
@@ -1487,8 +1488,27 @@ sub realtime_refund_bop {
         unless ($processor eq $conf_processor)
             || (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}));
 
+      $processor = $conf_processor;
+
     }
 
+    # if gateway has switched to CardFortress but token_check hasn't run yet,
+    # tokenize just this record now, so that token gets passed/set appropriately
+    if ($cust_pay->payby eq 'CARD' && !$cust_pay->tokenized) {
+      my %tokenopts = (
+        'payment_gateway' => $payment_gateway,
+        'method'          => 'CC',
+        'payinfo'         => $cust_pay->payinfo,
+        'paydate'         => $cust_pay->paydate,
+      );
+      my $error = $self->realtime_tokenize(\%tokenopts); # no-op unless gateway can tokenize
+      if ($self->tokenized($tokenopts{'payinfo'})) { # implies no error
+        warn "  tokenizing cust_pay\n" if $DEBUG > 1;
+        $cust_pay->payinfo($tokenopts{'payinfo'});
+        $error = $cust_pay->replace;
+      }
+      return $error if $error;
+    }
 
   } else { # didn't specify a paynum, so look for agent gateway overrides
            # like a normal transaction 
@@ -1567,6 +1587,12 @@ sub realtime_refund_bop {
         $content{'name'} = $self->get('first'). ' '. $self->get('last');
       }
     }
+    if ( $cust_pay->payby eq 'CARD'
+         && !$content{'card_number'}
+         && $cust_pay->tokenized
+    ) {
+      $content{'card_token'} = $cust_pay->payinfo;
+    }
     $void->content( 'action' => 'void', %content );
     $void->test_transaction(1)
       if $conf->exists('business-onlinepayment-test_transaction');
index 4e9ede3..f45abc6 100644 (file)
@@ -38,6 +38,8 @@ use FS::sales;
 # for modify_charge
 use FS::cust_credit;
 
+use Data::Dumper;
+
 # temporary fix; remove this once (un)suspend admin notices are cleaned up
 use FS::Misc qw(send_email);
 
@@ -3010,7 +3012,7 @@ sub modify_charge {
       $pkg_opt_modified = 1;
     }
   }
-  $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i;
+  $pkg_opt_modified = 1 if scalar(@old_additional) != $i;
   $pkg_opt{'additional_count'} = $i if $i > 0;
 
   my $old_classnum;
@@ -3164,9 +3166,6 @@ sub modify_charge {
   '';
 }
 
-
-
-use Data::Dumper;
 sub process_bulk_cust_pkg {
   my $job = shift;
   my $param = shift;
@@ -5575,6 +5574,32 @@ sub _upgrade_data {  # class method
     my $error = $part_pkg_link->remove_linked;
     die $error if $error;
   }
+
+  # RT#73607: canceling a package with billing addons sometimes changes its
+  # pkgpart.
+  # Find records where the last replace_new record for the package before it
+  # was canceled has a different pkgpart from the package itself.
+  my @cust_pkg = qsearch({
+    'table' => 'cust_pkg',
+    'select' => 'cust_pkg.*, h_cust_pkg.pkgpart AS h_pkgpart',
+    'addl_from' => ' JOIN (
+  SELECT pkgnum, MAX(historynum) AS historynum FROM h_cust_pkg
+    WHERE cancel IS NULL
+      AND history_action = \'replace_new\'
+    GROUP BY pkgnum
+  ) AS last_history USING (pkgnum)
+  JOIN h_cust_pkg USING (historynum)',
+    'extra_sql' => ' WHERE cust_pkg.cancel is not null
+                     AND cust_pkg.pkgpart != h_cust_pkg.pkgpart'
+  });
+  foreach my $cust_pkg ( @cust_pkg ) {
+    my $pkgnum = $cust_pkg->pkgnum;
+    warn "fixing pkgpart on canceled pkg#$pkgnum\n";
+    $cust_pkg->set('pkgpart', $cust_pkg->h_pkgpart);
+    my $error = $cust_pkg->replace;
+    die $error if $error;
+  }
+
 }
 
 =back
index 35f178e..956cf79 100644 (file)
@@ -401,19 +401,20 @@ I<bulk_skip>, I<provision_hold> and I<options>
 
 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
 values, the appropriate FS::pkg_svc records will be replaced.  I<hidden_svc>
-can be set to a hashref of svcparts and flag values ('Y' or '') to set the 
-'hidden' field in these records.  I<bulk_skip> and I<provision_hold> can be set 
-to a hashref of svcparts and flag values ('Y' or '') to set the respective field 
-in those records.
+can be set to a hashref of svcparts and flag values ('Y' or '') to set the
+'hidden' field in these records.  I<bulk_skip> and I<provision_hold> can be
+set to a hashref of svcparts and flag values ('Y' or '') to set the
+respective field in those records.
 
-If I<primary_svc> is set to the svcpart of the primary service, the appropriate
-FS::pkg_svc record will be updated.
+If I<primary_svc> is set to the svcpart of the primary service, the
+appropriate FS::pkg_svc record will be updated.
 
-If I<options> is set to a hashref, the appropriate FS::part_pkg_option records
-will be replaced.
+If I<options> is set to a hashref, the appropriate FS::part_pkg_option
+records will be replaced.
 
 If I<part_pkg_currency> is set to a hashref of options (with the keys as
-option_CURRENCY), appropriate FS::part_pkg::currency records will be replaced.
+option_CURRENCY), appropriate FS::part_pkg::currency records will be
+replaced.
 
 =cut
 
@@ -735,6 +736,7 @@ sub check {
     || $self->ut_floatn('pay_weight')
     || $self->ut_floatn('credit_weight')
     || $self->ut_numbern('taxproductnum')
+    || $self->ut_numbern('units_taxproductnum')
     || $self->ut_foreign_keyn('classnum',       'pkg_class', 'classnum')
     || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum')
     || $self->ut_foreign_keyn('taxproductnum',
@@ -1731,6 +1733,19 @@ sub taxproduct_description {
   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
 }
 
+=item units_taxproduct
+
+Returns the L<FS::part_pkg_taxproduct> record used to report the taxable
+service units (usually phone lines) on this package.
+
+=cut
+
+sub units_taxproduct {
+  my $self = shift;
+  $self->units_taxproductnum
+    ? FS::part_pkg_taxproduct->by_key($self->units_taxproductnum)
+    : '';
+}
 
 =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
 
@@ -2345,6 +2360,56 @@ sub queueable_upgrade {
       die $error if $error;
     }
   }
+
+  # remove custom flag from one-time charge packages that were accidentally
+  # flagged as custom
+  $search = FS::Cursor->new({
+    'table'   => 'part_pkg',
+    'hashref' => { 'freq'   => '0',
+                   'custom' => 'Y',
+                   'family_pkgpart' => { op => '!=', value => '' },
+                 },
+    'addl_from' => ' JOIN
+  (select pkgpart from cust_pkg group by pkgpart having count(*) = 1)
+    AS singular_pkg USING (pkgpart)',
+  });
+  my @fields = grep {     $_ ne 'pkgpart'
+                      and $_ ne 'custom'
+                      and $_ ne 'disabled' } FS::part_pkg->fields;
+  PKGPART: while (my $part_pkg = $search->fetch) {
+    # can't merge the package back into its parent (too late for that)
+    # but we can remove the custom flag if it's not actually customized,
+    # i.e. nothing has been changed.
+
+    my $family_pkgpart = $part_pkg->family_pkgpart;
+    next PKGPART if $family_pkgpart == $part_pkg->pkgpart;
+    my $parent_pkg = FS::part_pkg->by_key($family_pkgpart);
+    foreach my $field (@fields) {
+      if ($part_pkg->get($field) ne $parent_pkg->get($field)) {
+        next PKGPART;
+      }
+    }
+    # options have to be identical too
+    # but links, FCC options, discount plans, and usage packages can't be
+    # changed through the "modify charge" UI, so skip them
+    my %newopt = $part_pkg->options;
+    my %oldopt = $parent_pkg->options;
+    OPTION: foreach my $option (keys %newopt) {
+      if (delete $newopt{$option} ne delete $oldopt{$option}) {
+        next PKGPART;
+      }
+    }
+    if (keys(%newopt) or keys(%oldopt)) {
+      next PKGPART;
+    }
+    # okay, now replace it
+    warn "Removing custom flag from part_pkg#".$part_pkg->pkgpart."\n";
+    $part_pkg->set('custom', '');
+    my $error = $part_pkg->replace;
+    die $error if $error;
+  } # $search->fetch
+
+  return;
 }
 
 =item curuser_pkgs_sql
index e86d028..51bc37f 100644 (file)
@@ -223,7 +223,8 @@ sub batch_import {
   
   my $imported = 0;
   my $csv = Text::CSV_XS->new;
-  # fields: taxproduct, description
+  my $error;
+  # for importing the "transervdesc.txt" file
   while ( my $row = $csv->getline($fh) ) {
     if (!defined $row) {
       $dbh->rollback if $oldAutoCommit;
@@ -236,15 +237,32 @@ sub batch_import {
       );
     }
 
-    my $new = FS::part_pkg_taxproduct->new({
-        'data_vendor' => 'billsoft',
-        'taxproduct'  => $row->[0],
-        'description' => $row->[1],
+    # columns 0-2: irrelevant here
+    my $taxproduct = $row->[3] . ':' . $row->[5];
+    my $description = $row->[4];
+    $description =~ s/\s+$//;
+    $description .= ':' . $row->[6];
+    $description =~ s/\s+$//;
+    my $ppt = qsearchs('part_pkg_taxproduct', {
+      'data_vendor' => 'billsoft',
+      'taxproduct'  => $taxproduct
     });
-    my $error = $new->insert;
+    if ( $ppt ) {
+      $ppt->set('description', $description);
+      $ppt->set('note', $row->[7]);
+      $error = $ppt->replace;
+    } else {
+      $ppt = FS::part_pkg_taxproduct->new({
+          'data_vendor' => 'billsoft',
+          'taxproduct'  => $taxproduct,
+          'description' => $description,
+          'note'        => $row->[7],
+      });
+      $error = $ppt->insert;
+    }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error inserting part_pkg_taxproduct: $error\n";
+      return "error inserting part_pkg_taxproduct $taxproduct: $error\n";
     }
     $imported++;
   }
diff --git a/FS/t/suite/14-tokenization_refund.t b/FS/t/suite/14-tokenization_refund.t
new file mode 100755 (executable)
index 0000000..1a0f840
--- /dev/null
@@ -0,0 +1,246 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Test;
+use Test::More;
+use FS::Conf;
+use FS::cust_main;
+use Business::CreditCard qw(generate_last_digit);
+use DateTime;
+if ( stat('/usr/local/etc/freeside/cardfortresstest.txt') ) {
+  plan tests => 66;
+} else {
+  plan skip_all => 'CardFortress test encryption key is not installed.';
+}
+
+#local $FS::cust_main::Billing_Realtime::DEBUG = 2;
+
+my $fs = FS::Test->new( user => 'admin' );
+my $conf = FS::Conf->new;
+my $err;
+my @bopconf;
+
+### can only run on test database (company name "Freeside Test")
+like( $conf->config('company_name'), qr/^Freeside Test/, 'using test database' ) or BAIL_OUT('');
+
+# these will just get in the way for now
+foreach my $apg ($fs->qsearch('agent_payment_gateway')) {
+  $err = $apg->delete;
+  last if $err;
+}
+ok( !$err, 'removing agent gateway overrides' ) or BAIL_OUT($err);
+
+# will need this
+my $reason = FS::reason->new_or_existing(
+  reason => 'Token Test',
+  type   => 'Refund',
+  class  => 'F',
+);
+isa_ok ( $reason, 'FS::reason', "refund reason" ) or BAIL_OUT('');
+
+# non-tokenizing gateway
+push @bopconf,
+'IPPay
+TESTTERMINAL';
+
+# tokenizing gateway
+push @bopconf,
+'CardFortress
+cardfortresstest
+(TEST54)
+Normal Authorization
+gateway
+IPPay
+gateway_login
+TESTTERMINAL
+gateway_password
+
+private_key
+/usr/local/etc/freeside/cardfortresstest.txt';
+
+foreach my $voiding (0,1) {
+  my $noun = $voiding ? 'void' : 'refund';
+
+  if ($voiding) {
+    $conf->delete('disable_void_after');
+    ok( !$conf->exists('disable_void_after'), 'set disable_void_after to produce voids' ) or BAIL_OUT('');
+  } else {
+    $conf->set('disable_void_after' => '0');
+    is( $conf->config('disable_void_after'), '0', 'set disable_void_after to produce refunds' ) or BAIL_OUT('');
+  }
+
+  # for attempting refund post-tokenization
+  my $n_cust_main;
+  my $n_cust_pay;
+
+  foreach my $tokenizing (0,1) {
+    my $adj = $tokenizing ? 'tokenizable' : 'non-tokenizable';
+
+    # set payment gateway
+    $conf->set('business-onlinepayment' => $bopconf[$tokenizing]);
+    is( join("\n",$conf->config('business-onlinepayment')), $bopconf[$tokenizing], "set $adj $noun default gateway" ) or BAIL_OUT('');
+
+    # make sure we're upgraded, only need to do it once,
+    # use non-tokenizing gateway for speed,
+    # but doesn't matter if existing records are tokenized or not,
+    # this suite is all about testing new record creation
+    if (!$tokenizing && !$voiding) {
+      $err = system('freeside-upgrade','-q','admin');
+      ok( !$err, 'upgrade freeside' ) or BAIL_OUT('Error string: '.$!);
+    }
+
+    if ($tokenizing) {
+
+      my $n_paynum = $n_cust_pay->paynum;
+
+      # refund the previous non-tokenized payment through CF
+      $err = $n_cust_main->realtime_refund_bop({
+        reasonnum => $reason->reasonnum,
+        paynum    => $n_paynum,
+        method    => 'CC',
+      });
+      ok( !$err, "run post-switch $noun" ) or BAIL_OUT($err);
+
+      my $n_cust_pay_void = $fs->qsearchs('cust_pay_void',{ paynum => $n_paynum });
+      my $n_cust_refund   = $fs->qsearchs('cust_refund',{ source_paynum => $n_paynum });
+
+      if ($voiding) {
+
+        # check for void record
+        isa_ok( $n_cust_pay_void, 'FS::cust_pay_void', 'post-switch void') or BAIL_OUT("paynum $n_paynum");
+
+        # check that void tokenized
+        ok ( $n_cust_pay_void->tokenized, "post-switch void tokenized" ) or BAIL_OUT("paynum $n_paynum");
+
+        # check for no refund record
+        ok( !$n_cust_refund, "post-switch void did not generate cust_refund" ) or BAIL_OUT("paynum $n_paynum");
+
+      } else {
+
+        # check for refund record
+        isa_ok( $n_cust_refund, 'FS::cust_refund', 'post-switch refund') or BAIL_OUT("paynum $n_paynum");
+
+        # check that refund tokenized
+        ok ( $n_cust_refund->tokenized, "post-switch refund tokenized" ) or BAIL_OUT("paynum $n_paynum");
+
+        # check for no refund record
+        ok( !$n_cust_pay_void, "post-switch refund did not generate cust_pay_void" ) or BAIL_OUT("paynum $n_paynum");
+
+      }
+
+    }
+
+    # create customer
+    my $cust_main = $fs->new_customer($adj.'X'.$noun);
+    isa_ok ( $cust_main, 'FS::cust_main', "$adj $noun customer" ) or BAIL_OUT('');
+
+    # insert customer
+    $err = $cust_main->insert;
+    ok( !$err, "insert $adj $noun customer" ) or BAIL_OUT($err);
+
+    # add card
+    my $cust_payby;
+    my %card = random_card();
+    $err = $cust_main->save_cust_payby(
+      %card,
+      payment_payby => $card{'payby'},
+      auto => 1,
+      saved_cust_payby => \$cust_payby
+    );
+    ok( !$err, "save $adj $noun card" ) or BAIL_OUT($err);
+
+    # retrieve card
+    isa_ok ( $cust_payby, 'FS::cust_payby', "$adj $noun card" ) or BAIL_OUT('');
+
+    # check that card tokenized or not
+    if ($tokenizing) {
+      ok( $cust_payby->tokenized, "new $noun cust card tokenized" ) or BAIL_OUT('');
+    } else {
+      ok( !$cust_payby->tokenized, "new $noun cust card not tokenized" ) or BAIL_OUT('');
+    }
+
+    # run a payment
+    $err = $cust_main->realtime_cust_payby( amount => '1.00' );
+    ok( !$err, "run $adj $noun payment" ) or BAIL_OUT($err);
+
+    # get the payment
+    my $cust_pay = $fs->qsearchs('cust_pay',{ custnum => $cust_main->custnum }); 
+    isa_ok ( $cust_pay, 'FS::cust_pay', "$adj $noun payment" ) or BAIL_OUT('');
+
+    # refund the payment
+    $err = $cust_main->realtime_refund_bop({
+      reasonnum => $reason->reasonnum,
+      paynum    => $cust_pay->paynum,
+      method    => 'CC',
+    });
+    ok( !$err, "run $adj $noun" ) or BAIL_OUT($err);
+
+    unless ($tokenizing) {
+
+      # run a second payment, to refund after switch
+      $err = $cust_main->realtime_cust_payby( amount => '2.00' );
+      ok( !$err, "run $adj $noun second payment" ) or BAIL_OUT($err);
+    
+      # get the second payment
+      $n_cust_pay = $fs->qsearchs('cust_pay',{ custnum => $cust_main->custnum, paid => '2.00' });
+      isa_ok ( $n_cust_pay, 'FS::cust_pay', "$adj $noun second payment" ) or BAIL_OUT('');
+
+      $n_cust_main = $cust_main;
+
+    }
+
+    #check that all transactions tokenized or not
+    foreach my $table (qw(cust_pay_pending cust_pay cust_pay_void cust_refund)) {
+      foreach my $record ($fs->qsearch($table,{ custnum => $cust_main->custnum })) {
+        if ($tokenizing) {
+          $err = "record not tokenized: $table ".$record->get($record->primary_key)
+            unless $record->tokenized;
+        } else {
+          $err = "record tokenized: $table ".$record->get($record->primary_key)
+            if $record->tokenized;
+        }
+        last if $err;
+      }
+    }
+    ok( !$err, "$adj transaction token check" ) or BAIL_OUT($err);
+
+    if ($voiding) {
+
+      #make sure we voided
+      ok( $fs->qsearch('cust_pay_void',{ custnum => $cust_main->custnum}), "$adj $noun record found" ) or BAIL_OUT('');
+
+      #make sure we didn't generate refund records
+      ok( !$fs->qsearch('cust_refund',{ custnum => $cust_main->custnum}), "$adj $noun did not generate cust_refund" ) or BAIL_OUT('');
+
+    } else {
+
+      #make sure we refunded
+      ok( $fs->qsearch('cust_refund',{ custnum => $cust_main->custnum}), "$adj $noun record found" ) or BAIL_OUT('');
+
+      #make sure we didn't generate void records
+      ok( !$fs->qsearch('cust_pay_void',{ custnum => $cust_main->custnum}), "$adj $noun did not generate cust_pay_void" ) or BAIL_OUT('');
+
+    }
+
+  } #end of tokenizing or not
+
+} # end of voiding or not
+
+exit;
+
+sub random_card {
+  my $payinfo = '4111' . join('', map { int(rand(10)) } 1 .. 11);
+  $payinfo .= generate_last_digit($payinfo);
+  my $paydate = DateTime->now
+                ->add('years' => 1)
+                ->truncate(to => 'month')
+                ->strftime('%F');
+  return ( 'payby'    => 'CARD',
+           'payinfo'  => $payinfo,
+           'paydate'  => $paydate,
+           'payname'  => 'Tokenize Me',
+  );
+}
+
+1;
+
index acc3211..8c51b35 100755 (executable)
@@ -601,12 +601,18 @@ if ( $taxclasses ) {
         { 'data'  => &$taxproduct_sub($base_ppt), 'align' => 'right' },
       ];
     }
+    if ( my $units_ppt = $part_pkg->units_taxproduct ) {
+      push @$out, [
+        { 'data'  => emt('Lines'), 'align' => 'left' },
+        { 'data'  => &$taxproduct_sub($units_ppt), 'align' => 'right' },
+      ];
+    }
     for (my $i = 0; $i < scalar @classnums; $i++) {
       my $num = $part_pkg->option('usage_taxproductnum_' . $classnums[$i]);
       next if !$num;
       my $ppt = FS::part_pkg_taxproduct->by_key($num);
       push @$out, [
-        { 'data'  => $classnames[$i] . ': ', 'align' => 'left', },
+        { 'data'  => $classnames[$i], 'align' => 'left', },
         { 'data'  => &$taxproduct_sub($ppt), 'align' => 'right' },
       ];
     }
index 64a7525..84aac5b 100755 (executable)
@@ -40,7 +40,6 @@
                    'setuptax'         => 'Setup fee tax exempt',
                    'recurtax'         => 'Recurring fee tax exempt',
                    'taxclass'         => 'Tax class',
-                   'taxproduct_select'=> 'Tax products',
                    'plan'             => 'Price plan',
                    'disabled'         => 'Disable new orders',
                    'disable_line_item_date_ranges' => 'Disable line item date ranges',
@@ -73,6 +72,7 @@
                    'contract_end_months' => 'Contract ends after ',
                    'expire_months'    => 'Cancel the package after ',
                    'change_to_pkgpart'=> 'and replace it with ',
+                   'units_taxproductnum' => 'Per-line tax product',
                  },
 
      'fields' => [
                        type  => 'hidden',
                        value => join(',', @taxproductnums),
                      },
-                     #{ field => 'taxproduct_select',
-                     #  type  => 'selectlayers',
-                     #  options => [ '(default)', @taxproductnums ],
-                     #  curr_value => '(default)',
-                     #  labels  => { ( '(default)' => '(default)' ),
-                     #               map {($_=>$usage_class{$_})}
-                     #               @taxproductnums
-                     #             },
-                     #  layer_fields => \%taxproduct_fields,
-                     #  layer_values_callback => $taxproduct_values,
-                     #  layers_only  =>   !$taxproducts,
-                     #  cell_style   => ( !$taxproducts
-                     #                    ? 'display:none'
-                     #                    : ''
-                     #                  ),
-                     #},
                      { field => 'taxproductnum',
                        type  => 'part_pkg-taxproducts',
                        include_opt_callback =>
                          sub { pkgpart => $_[0]->pkgpart },
                      },
-
+                     { field => 'units_taxproductnum',
+                       type  => ($tax_data_vendor ?
+                                  'select-taxproduct' : 'hidden'),
+                     },
                      { type  => 'tablebreak-tr-title',
                        value => 'Promotions', #better name?
                      },
@@ -445,7 +432,7 @@ my $agent_clone_extra_sql =
   ' ) ';
 
 my $conf = new FS::Conf;
-my $taxproducts = $conf->config('tax_data_vendor') ne '';
+my $tax_data_vendor = $conf->config('tax_data_vendor');
 
 my $fcc_opts = $conf->exists('part_pkg-show_fcc_options');
 
@@ -1112,13 +1099,8 @@ my $html_bottom = sub {
   my $return =
     include('/elements/selectlayers.html', %selectlayers, 'layers_only'=>1 ).
     '<SCRIPT TYPE="text/javascript">'.
-      include('/elements/selectlayers.html', %selectlayers, 'js_only'=>1 );
-
-#  $return .=
-#    "taxproduct_selectchanged(document.getElementById('taxproduct_select'));\n"
-#      if $taxproducts;
-
-  $return .= '</SCRIPT>';
+      include('/elements/selectlayers.html', %selectlayers, 'js_only'=>1 ) .
+    '</SCRIPT>';
 
   $return;
 
@@ -1199,16 +1181,8 @@ my $field_callback = sub {
   my $field = $fieldref->{field};
   if ($field eq 'taxproductnums') {
     $fieldref->{value} = join(',', @taxproductnums);
-  } elsif ($field eq 'taxproduct_select') {
-    $fieldref->{options} = [ '(default)', @taxproductnums ];
-    $fieldref->{labels}  = { ( '(default)' => '(default)' ),
-                             map {( $_ => ($usage_class{$_} || $_) )}
-                               @taxproductnums
-                           };
-    $fieldref->{layer_fields} = \%taxproduct_fields;
-    $fieldref->{layer_values_callback} = $taxproduct_values;
   } elsif ($field eq 'taxproductnum') { # part_pkg-taxproduct, new style
-    if ( !$taxproducts ) {
+    if ( !$tax_data_vendor ) {
       # then make the widget go away
       $fieldref->{type} = 'hidden';
     }
index 5dcea09..50dace7 100644 (file)
@@ -54,7 +54,8 @@ my %pkg_options;
 if ($pkgpart) {
   my $part_pkg = FS::part_pkg->by_key($pkgpart);
   %pkg_options = $part_pkg->options;
-  $curr_values{''} = $part_pkg->taxproductnum;
+  $curr_values{''} =   $cgi->param('taxproductnum')
+                    || $part_pkg->taxproductnum;
 }
 
 foreach my $usage_class (@classes) {
@@ -66,4 +67,5 @@ foreach my $usage_class (@classes) {
   $curr_values{$classnum} = $curr_value;
   $separate = 1 if ( length($classnum) and length($curr_value) );
 }
+
 </%init>