Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Sat, 5 Jan 2013 22:49:44 +0000 (14:49 -0800)
committerIvan Kohler <ivan@freeside.biz>
Sat, 5 Jan 2013 22:49:44 +0000 (14:49 -0800)
49 files changed:
FS/FS/Report/Table.pm
FS/FS/Report/Table/Monthly.pm
FS/FS/Schema.pm
FS/FS/TicketSystem.pm
FS/FS/TicketSystem/RT_Internal.pm
FS/FS/cust_bill.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_main_county.pm
FS/FS/cust_pay.pm
FS/FS/cust_pay_void.pm
FS/FS/cust_refund.pm
FS/FS/payinfo_Mixin.pm
FS/FS/payinfo_transaction_Mixin.pm
FS/bin/freeside-ipifony-download
FS/bin/freeside-void-payments
Makefile
httemplate/edit/cust_refund.cgi
httemplate/edit/process/cust_pay.cgi
httemplate/graph/cust_bill_pkg.cgi
httemplate/graph/elements/monthly.html
httemplate/graph/money_time.cgi
httemplate/graph/money_time_daily.cgi
httemplate/graph/report_cust_bill_pkg.html
httemplate/graph/report_money_time.html
httemplate/graph/report_money_time_daily.html
httemplate/search/cust_bill.html
httemplate/search/cust_bill_pay.html
httemplate/search/cust_bill_pkg.cgi
httemplate/search/cust_bill_pkg_referral.html
httemplate/search/cust_credit.html
httemplate/search/cust_credit_refund.html
httemplate/search/customer_accounting_summary.html
httemplate/search/elements/cust_pay_or_refund.html
httemplate/search/elements/report_cust_pay_or_refund.html
httemplate/search/prepaid_income.html
httemplate/search/report_cust_bill_pkg.html
httemplate/search/report_cust_bill_pkg_referral.html
httemplate/search/report_customer_accounting_summary.html
httemplate/search/report_prepaid_income.html
httemplate/search/unearned_detail.html
httemplate/view/cust_pay.html
httemplate/view/cust_refund.html
rt/lib/RT/Action/Accumulate.pm
rt/lib/RT/URI/freeside/Internal.pm
rt/share/html/Ticket/Elements/EditCustomers
rt/share/html/Ticket/Elements/ShowCustomers

index 6969406..2e202e5 100644 (file)
@@ -56,6 +56,13 @@ sub signups {
     push @where, "refnum = ".$opt{'refnum'};
   }
 
+  if ( $opt{'cust_classnum'} ) {
+    my $classnums = $opt{'cust_classnum'};
+    $classnums = [ $classnums ] if !ref($classnums);
+    @$classnums = grep /^\d+$/, @$classnums;
+    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')';
+  }
+
   $self->scalar_sql(
     "SELECT COUNT(*) FROM cust_main $join WHERE ".join(' AND ', @where)
   );
@@ -439,8 +446,16 @@ sub cust_bill_pkg_setup {
     $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
   );
 
+  # yuck, false laziness
   push @where, "cust_main.refnum = ". $opt{'refnum'} if $opt{'refnum'};
 
+  if ( $opt{'cust_classnum'} ) {
+    my $classnums = $opt{'cust_classnum'};
+    $classnums = [ $classnums ] if !ref($classnums);
+    @$classnums = grep /^\d+$/, @$classnums;
+    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')';
+  }
+
   my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
   FROM cust_bill_pkg
   $cust_bill_pkg_join
@@ -463,6 +478,13 @@ sub cust_bill_pkg_recur {
 
   push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
 
+  if ( $opt{'cust_classnum'} ) {
+    my $classnums = $opt{'cust_classnum'};
+    $classnums = [ $classnums ] if !ref($classnums);
+    @$classnums = grep /^\d+$/, @$classnums;
+    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')';
+  }
+
   # subtract all usage from the line item regardless of date
   my $item_usage;
   if ( $opt{'project'} ) {
@@ -518,6 +540,13 @@ sub cust_bill_pkg_detail {
 
   push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
 
+  if ( $opt{'cust_classnum'} ) {
+    my $classnums = $opt{'cust_classnum'};
+    $classnums = [ $classnums ] if !ref($classnums);
+    @$classnums = grep /^\d+$/, @$classnums;
+    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')';
+  }
+
   $agentnum ||= $opt{'agentnum'};
 
   push @where,
@@ -657,6 +686,14 @@ sub for_opts {
     if ( $opt{'refnum'} =~ /^(\d+)$/ ) {
       $sql .= " and refnum = $1 ";
     }
+    if ( $opt{'cust_classnum'} ) {
+      my $classnums = $opt{'cust_classnum'};
+      $classnums = [ $classnums ] if !ref($classnums);
+      @$classnums = grep /^\d+$/, @$classnums;
+      $sql .= ' and cust_main.classnum in('. join(',',@$classnums) .')'
+        if @$classnums;
+    }
+
     $sql;
 }
 
index ee4dc5f..b8e52ae 100644 (file)
@@ -25,6 +25,7 @@ FS::Report::Table::Monthly - Tables of report data, indexed monthly
     #opt
     'agentnum'    => 54
     'refnum'      => 54
+    'cust_classnum' => [ 1,2,4 ],
     'params'      => [ [ 'paramsfor', 'item_one' ], [ 'item', 'two' ] ], # ...
     'remove_empty' => 1, #collapse empty rows, default 0
     'item_labels' => [ ], #useful with remove_empty
@@ -69,6 +70,9 @@ corresponding to this arrayref.
 
 =item refnum: Limit to customers with this advertising source.
 
+=item cust_classnum: Limit to customers with this classnum; can be an 
+arrayref.
+
 =item remove_empty: Set this to a true value to hide rows that contain 
 only zeroes.  The C<indices> array in the returned data will list the item
 indices that are actually present in the output so that you know what they
@@ -139,6 +143,8 @@ sub data {
 
   my $agentnum = $self->{'agentnum'};
   my $refnum = $self->{'refnum'};
+  my $cust_classnum = $self->{'cust_classnum'} || [];
+  $cust_classnum = [ $cust_classnum ] if !ref($cust_classnum);
 
   if ( $projecting ) {
 
@@ -183,6 +189,7 @@ sub data {
       my @param = $self->{'params'} ? @{ $self->{'params'}[$col] }: ();
       push @param, 'project', $projecting;
       push @param, 'refnum' => $refnum if $refnum;
+      push @param, 'cust_classnum' => $cust_classnum if @$cust_classnum;
 
       if ( $self->{'cross_params'} ) {
         my @xdata;
index b6fd3b6..cbcd27b 100644 (file)
@@ -1560,7 +1560,14 @@ sub tables_hashref {
         'depositor',  'varchar', 'NULL', $char_d, '', '',
         'account',    'varchar', 'NULL', 20,      '', '',
         'teller',     'varchar', 'NULL', 20,      '', '',
+
         'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key
+
+        # credit card/EFT fields (formerly in paybatch)
+        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK
+        'processor',  'varchar', 'NULL', $char_d, '', '', # module name
+        'auth',       'varchar','NULL',16, '', '', # CC auth number
+        'order_number','varchar','NULL',$char_d, '', '', # transaction number
       ],
       'primary_key' => 'paynum',
       #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ],
@@ -1591,6 +1598,12 @@ sub tables_hashref {
         'teller',     'varchar', 'NULL', 20,      '', '',
         'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key
 
+        # credit card/EFT fields (formerly in paybatch)
+        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK
+        'processor',  'varchar', 'NULL', $char_d, '', '', # module name
+        'auth',       'varchar','NULL',16, '', '', # CC auth number
+        'order_number', 'varchar','NULL',$char_d, '', '', # transaction number
+
         #void fields
         'void_date', @date_type, '', '', 
         'reason',    'varchar',   'NULL', $char_d, '', '', 
@@ -1858,6 +1871,11 @@ sub tables_hashref {
        'paymask', 'varchar', 'NULL', $char_d, '', '', 
         'paybatch',     'varchar',   'NULL', $char_d, '', '', 
         'closed',    'char', 'NULL', 1, '', '', 
+        # credit card/EFT fields (formerly in paybatch)
+        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK
+        'processor',  'varchar', 'NULL', $char_d, '', '', # module name
+        'auth',       'varchar','NULL',16, '', '', # CC auth number
+        'order_number', 'varchar','NULL',$char_d, '', '', # transaction number
       ],
       'primary_key' => 'refundnum',
       'unique' => [],
index c1553f1..165856e 100644 (file)
@@ -87,6 +87,8 @@ sub _upgrade_data {
   # bypass RT ACLs--we're going to do lots of things
   my $CurrentUser = $RT::SystemUser;
 
+  my $dbh = dbh;
+
   # selfservice and cron users
   foreach my $username ('%%%SELFSERVICE_USER%%%', 'fs_daily') {
     my $User = RT::User->new($CurrentUser);
@@ -252,6 +254,71 @@ sub _upgrade_data {
     die $msg if !$val;
   } #foreach (@Scrips)
 
+  # one-time fix: accumulator fields (support time, etc.) that had values 
+  # entered on ticket creation need OCFV records attached to their Create
+  # transactions
+  my $sql = 'SELECT first_ocfv.ObjectId, first_ocfv.Created, Content '.
+    'FROM ObjectCustomFieldValues as first_ocfv '.
+    'JOIN ('.
+      # subquery to get the first OCFV with a certain name for each ticket
+      'SELECT min(ObjectCustomFieldValues.Id) AS Id '.
+      'FROM ObjectCustomFieldValues '.
+      'JOIN CustomFields '.
+      'ON (ObjectCustomFieldValues.CustomField = CustomFields.Id) '.
+      'WHERE ObjectType = \'RT::Ticket\' '.
+      'AND CustomFields.Name = ? '.
+      'GROUP BY ObjectId'.
+    ') AS first_ocfv_id USING (Id) '.
+    'JOIN ('.
+      # subquery to get the first transaction date for each ticket
+      # other than the Create
+      'SELECT ObjectId, min(Created) AS Created FROM Transactions '.
+      'WHERE ObjectType = \'RT::Ticket\' '.
+      'AND Type != \'Create\' '.
+      'GROUP BY ObjectId'.
+    ') AS first_txn ON (first_ocfv.ObjectId = first_txn.ObjectId) '.
+    # where the ticket custom field acquired a value before any transactions
+    # on the ticket (i.e. it was set on ticket creation)
+    'WHERE first_ocfv.Created < first_txn.Created '.
+    # and we haven't already fixed the ticket
+    'AND NOT EXISTS('.
+      'SELECT 1 FROM Transactions JOIN ObjectCustomFieldValues '.
+      'ON (Transactions.Id = ObjectCustomFieldValues.ObjectId) '.
+      'JOIN CustomFields '.
+      'ON (ObjectCustomFieldValues.CustomField = CustomFields.Id) '.
+      'WHERE ObjectCustomFieldValues.ObjectType = \'RT::Transaction\' '.
+      'AND CustomFields.Name = ? '.
+      'AND Transactions.Type = \'Create\''.
+      'AND Transactions.ObjectType = \'RT::Ticket\''.
+      'AND Transactions.ObjectId = first_ocfv.ObjectId'.
+    ')';
+    #whew
+
+  # prior to this fix, the only name an accumulate field could possibly have 
+  # was "Support time".
+  my $sth = $dbh->prepare($sql);
+  $sth->execute('Support time', 'Support time');
+  my $rows = $sth->rows;
+  warn "Fixing support time on $rows rows...\n" if $rows > 0;
+  while ( my $row = $sth->fetchrow_arrayref ) {
+    my ($tid, $created, $content) = @$row;
+    my $Txns = RT::Transactions->new($CurrentUser);
+    $Txns->Limit(FIELD => 'ObjectId', VALUE => $tid);
+    $Txns->Limit(FIELD => 'ObjectType', VALUE => 'RT::Ticket');
+    $Txns->Limit(FIELD => 'Type', VALUE => 'Create');
+    my $CreateTxn = $Txns->First;
+    if ($CreateTxn) {
+      my ($val, $msg) = $CreateTxn->AddCustomFieldValue(
+        Field => 'Support time',
+        Value => $content,
+        RecordTransaction => 0,
+      );
+      warn "Error setting transaction support time: $msg\n" unless $val;
+    } else {
+      warn "Create transaction not found for ticket $tid.\n";
+    }
+  }
+
   return;
 }
 
index 01e2e29..665c166 100644 (file)
@@ -589,7 +589,7 @@ sub _web_external_auth {
          # we failed to successfully create the user. abort abort abort.
           delete $session->{'CurrentUser'};
 
-          die "can't auto-create RT user"; #an error message would be nice :/
+          die "can't auto-create RT user: $msg"; #an error message would be nice :/
           #$m->abort() unless $RT::WebFallbackToInternalAuth;
           #$m->comp( '/Elements/Login', %ARGS,
           #    Error => loc( 'Cannot create user: [_1]', $msg ) );
index e4b2df4..e7622d7 100644 (file)
@@ -3429,6 +3429,15 @@ sub search_sql_where {
     push @search, "cust_bill.custnum = $1";
   }
 
+  #customer classnum
+  if ( $param->{'cust_classnum'} ) {
+    my $classnums = $param->{'cust_classnum'};
+    $classnums = [ $classnums ] if !ref($classnums);
+    $classnums = [ grep /^\d+$/, @$classnums ];
+    push @search, 'cust_main.classnum in ('.join(',',@$classnums).')'
+      if @$classnums;
+  }
+
   #_date
   if ( $param->{_date} ) {
     my($beginning, $ending) = @{$param->{_date}};
index 5cff140..716c098 100644 (file)
@@ -201,16 +201,50 @@ sub insert {
 
   my $tax_location = $self->get('cust_bill_pkg_tax_location');
   if ( $tax_location ) {
-    foreach my $cust_bill_pkg_tax_location ( @$tax_location ) {
-      $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum);
-      $error = $cust_bill_pkg_tax_location->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "error inserting cust_bill_pkg_tax_location: $error";
+    foreach my $link ( @$tax_location ) {
+      next if $link->billpkgtaxlocationnum; # 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,
+      # set billpkgnum to ours and pass the link off to the cust_bill_pkg
+      # on the other side, to be inserted later.
+
+      my $tax_cust_bill_pkg = $link->get('tax_cust_bill_pkg');
+      if ( $tax_cust_bill_pkg && $tax_cust_bill_pkg->billpkgnum ) {
+        $link->set('billpkgnum', $tax_cust_bill_pkg->billpkgnum);
+        # break circular links when doing this
+        $link->set('tax_cust_bill_pkg', '');
       }
-    }
+      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
+        $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
+        $link->set('locationnum', 
+          $taxable_cust_bill_pkg->cust_pkg->tax_locationnum);
+        $link->set('taxable_cust_bill_pkg', '');
+      }
+
+      if ( $link->billpkgnum and $link->taxable_billpkgnum ) {
+        $error = $link->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "error inserting cust_bill_pkg_tax_location: $error";
+        }
+      } else { # handoff
+        my $other;
+        $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') || [];
+        push @$link_array, $link;
+        $other->set('cust_bill_pkg_tax_location' => $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 ) {
index 4ea4a6b..45d57cd 100644 (file)
@@ -3403,6 +3403,8 @@ New-style, with a hashref of options:
 
                                     'setuptax'   => '', # or 'Y' for tax exempt
 
+                                    'locationnum'=> 1234, # optional
+
                                     #internal taxation
                                     'taxclass'   => 'Tax class',
 
@@ -3434,6 +3436,7 @@ sub charge {
   my $no_auto = '';
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
+  my $locationnum;
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
@@ -3451,6 +3454,7 @@ sub charge {
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
+    $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
   } else {
     $amount     = shift;
     $quantity   = 1;
@@ -3517,6 +3521,7 @@ sub charge {
     'quantity'   => $quantity,
     'start_date' => $start_date,
     'no_auto'    => $no_auto,
+    'locationnum'=> $locationnum,
   } );
 
   $error = $cust_pkg->insert;
index 6d3ff91..cd46c73 100644 (file)
@@ -687,8 +687,6 @@ sub _omit_zero_value_bundles {
 
 =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
 
-This is a weird one.  Perhaps it should not even be exposed.
-
 Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
 Usually used internally by bill method B<bill>.
 
@@ -755,7 +753,7 @@ sub calculate_taxes {
   # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs
   my %tax_rate_location = ();
 
-  # keys are taxnums (not internal identifiers!)
+  # keys are taxlisthash keys (internal identifiers!)
   # values are arrayrefs of cust_tax_exempt_pkg objects
   my %tax_exemption;
 
@@ -775,45 +773,35 @@ sub calculate_taxes {
     # It also calculates exemptions and attaches them to the cust_bill_pkgs
     # in the argument.
     my $taxables = $taxlisthash->{$tax};
-    my $exemptions = $tax_exemption{$tax_object->taxnum} ||= [];
-    my $hashref_or_error =
-      $tax_object->taxline( $taxables,
+    my $exemptions = $tax_exemption{$tax} ||= [];
+    my $taxline = $tax_object->taxline(
+                            $taxables,
                             'custnum'      => $self->custnum,
                             'invoice_time' => $invoice_time,
                             'exemptions'   => $exemptions,
                           );
-    return $hashref_or_error unless ref($hashref_or_error);
-
-    # then collect any new exemptions generated for this tax
-    push @$exemptions, @{ $_->cust_tax_exempt_pkg }
-      foreach @$taxables;
+    return $taxline unless ref($taxline);
 
     unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
-    my $name   = $hashref_or_error->{'name'};
-    my $amount = $hashref_or_error->{'amount'};
+    if ( $tax_object->isa('FS::cust_main_county') ) {
+      # then $taxline is a real line item
+      push @{ $taxname{ $taxline->itemdesc } }, $taxline;
 
-    #warn "adding $amount as $name\n";
-    $taxname{ $name } ||= [];
-    push @{ $taxname{ $name } }, $tax;
+    } else {
+      # leave this as is for now
 
-    $tax_amount{ $tax } += $amount;
+      my $name   = $taxline->{'name'};
+      my $amount = $taxline->{'amount'};
 
-    # link records between cust_main_county/tax_rate and cust_location
-    $tax_location{ $tax } ||= [];
-    $tax_rate_location{ $tax } ||= [];
-    if ( ref($tax_object) eq 'FS::cust_main_county' ) {
-      push @{ $tax_location{ $tax }  },
-        {
-          'taxnum'      => $tax_object->taxnum, 
-          'taxtype'     => ref($tax_object),
-          'pkgnum'      => $tax_object->get('pkgnum'),
-          'locationnum' => $tax_object->get('locationnum'),
-          'amount'      => sprintf('%.2f', $amount ),
-          'taxable_billpkgnum' => $tax_object->get('billpkgnum'),
-        };
-    }
-    elsif ( ref($tax_object) eq 'FS::tax_rate' ) {
+      #warn "adding $amount as $name\n";
+      $taxname{ $name } ||= [];
+      push @{ $taxname{ $name } }, $tax;
+
+      $tax_amount{ $tax } += $amount;
+
+      # link records between cust_main_county/tax_rate and cust_location
+      $tax_rate_location{ $tax } ||= [];
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
@@ -823,56 +811,53 @@ sub calculate_taxes {
           'amount'             => sprintf('%.2f', $amount ),
           'locationtaxid'      => $tax_object->location,
           'taxratelocationnum' => $taxratelocationnum,
-          'taxable_billpkgnum' => $tax_object->get('billpkgnum'),
         };
-    }
-
-  }
-
-  #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
-  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
-  foreach my $tax ( keys %$taxlisthash ) {
-    my $taxables = $taxlisthash->{$tax};
-    my $tax_object = shift @$taxables; # the rest are line items
-    foreach my $cust_bill_pkg ( @$taxables ) {
-      next unless ref($cust_bill_pkg) eq 'FS::cust_bill_pkg'; #IS needed for CCH tax-on-tax
-
-      my @cust_tax_exempt_pkg = splice @{ $cust_bill_pkg->cust_tax_exempt_pkg };
-
-      next unless @cust_tax_exempt_pkg;
-      # get the non-disintegrated version
-      my $real_cust_bill_pkg = $packagemap{$cust_bill_pkg->pkgnum}
-        or die "can't distribute tax exemptions: no line item for ".
-          Dumper($_). " in packagemap ". 
-          join(',', sort {$a<=>$b} keys %packagemap). "\n";
-
-      push @{ $real_cust_bill_pkg->cust_tax_exempt_pkg },
-           @cust_tax_exempt_pkg;
-    }
-  }
+    } #if ref($tax_object)...
+  } #foreach keys %$taxlisthash
 
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
+    my @cust_bill_pkg_tax_location;
+    my @cust_bill_pkg_tax_rate_location;
+    my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({
+        'pkgnum'    => 0,
+        'recur'     => 0,
+        'sdate'     => '',
+        'edate'     => '',
+        'itemdesc'  => $taxname,
+        'cust_bill_pkg_tax_location'      => \@cust_bill_pkg_tax_location,
+        'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
+    });
+
     my $tax_total = 0;
     my %seen = ();
-    my @cust_bill_pkg_tax_location = ();
-    my @cust_bill_pkg_tax_rate_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
-      next if $seen{$taxitem}++;
-      warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
-      $tax_total += $tax_amount{$taxitem};
-      push @cust_bill_pkg_tax_location,
-        map { new FS::cust_bill_pkg_tax_location $_ }
-            @{ $tax_location{ $taxitem } };
-      push @cust_bill_pkg_tax_rate_location,
-        map { new FS::cust_bill_pkg_tax_rate_location $_ }
-            @{ $tax_rate_location{ $taxitem } };
+      if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) {
+        # then we need to transfer the amount and the links from the
+        # line item to the new one we're creating.
+        $tax_total += $taxitem->setup;
+        foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) {
+          $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+          push @cust_bill_pkg_tax_location, $link;
+        }
+      } else {
+        # the tax_rate way
+        next if $seen{$taxitem}++;
+        warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
+        $tax_total += $tax_amount{$taxitem};
+        push @cust_bill_pkg_tax_rate_location,
+          map { new FS::cust_bill_pkg_tax_rate_location $_ }
+              @{ $tax_rate_location{ $taxitem } };
+      }
     }
     next unless $tax_total;
 
+    # we should really neverround this up...I guess it's okay if taxline 
+    # already returns amounts with 2 decimal places
     $tax_total = sprintf('%.2f', $tax_total );
+    $tax_cust_bill_pkg->set('setup', $tax_total);
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -890,19 +875,9 @@ sub calculate_taxes {
       push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
 
     }
+    $tax_cust_bill_pkg->set('display', \@display);
 
-    push @tax_line_items, new FS::cust_bill_pkg {
-      'pkgnum'   => 0,
-      'setup'    => $tax_total,
-      'recur'    => 0,
-      'sdate'    => '',
-      'edate'    => '',
-      'itemdesc' => $taxname,
-      'display'  => \@display,
-      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
-    };
-
+    push @tax_line_items, $tax_cust_bill_pkg;
   }
 
   \@tax_line_items;
@@ -1184,11 +1159,23 @@ sub _make_lines {
       # handle taxes
       ###
 
-      unless ( $discount_show_always ) {
-         my $error = 
-           $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
-         return $error if $error;
-      }
+      #unless ( $discount_show_always ) { # oh, for god's sake
+      my $error = $self->_handle_taxes(
+        $part_pkg,
+        $taxlisthash,
+        $cust_bill_pkg,
+        $cust_pkg,
+        $options{invoice_time},
+        $real_pkgpart,
+        \%options # I have serious objections to this
+      );
+      return $error if $error;
+      #}
+
+      $cust_bill_pkg->set_display(
+        part_pkg     => $part_pkg,
+        real_pkgpart => $real_pkgpart,
+      );
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
 
@@ -1200,6 +1187,25 @@ sub _make_lines {
 
 }
 
+# This is _handle_taxes.  It's called once for each cust_bill_pkg generated
+# from _make_lines, along with the part_pkg, cust_pkg, invoice time, the 
+# non-overridden pkgpart, a flag indicating whether the package is being
+# canceled, and a partridge in a pear tree.
+#
+# The most important argument is 'taxlisthash'.  This is shared across the 
+# entire invoice.  It looks like this:
+# {
+#   'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
+#   'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
+# }
+#
+# 'cust_main_county' can also be 'tax_rate'.  The first object in the array
+# is always the cust_main_county or tax_rate identified by the key.
+#
+# That "..." is a list of FS::cust_bill_pkg objects that will be fed to 
+# the 'taxline' method to calculate the amount of the tax.  This doesn't
+# happen until calculate_taxes, though.
+
 sub _handle_taxes {
   my $self = shift;
   my $part_pkg = shift;
@@ -1212,175 +1218,152 @@ sub _handle_taxes {
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my %cust_bill_pkg = ();
-  my %taxes = ();
-    
-  my @classes;
-  #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
-  push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-  push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
-  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
-
-  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 !~ /Y/i && $self->payby ne 'COMP' ) {
-  if ( $self->payby ne 'COMP' ) {
-
-    if ( $conf->exists('enable_taxproducts')
-         && ( scalar($part_pkg->part_pkg_taxoverride)
-              || $part_pkg->has_taxproduct
-            )
-       )
-    {
+  return if ( $self->payby eq 'COMP' ); #dubious
 
-      if ( !$exempt ) {
+  if ( $conf->exists('enable_taxproducts')
+       && ( scalar($part_pkg->part_pkg_taxoverride)
+            || $part_pkg->has_taxproduct
+          )
+     )
+    {
 
-        foreach my $class (@classes) {
-          my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
-          return $err_or_ref unless ref($err_or_ref);
-          $taxes{$class} = $err_or_ref;
-        }
+    # EXTERNAL TAX RATES (via tax_rate)
+    my %cust_bill_pkg = ();
+    my %taxes = ();
+
+    my @classes;
+    #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
+    push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+    # debatable
+    push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+    push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+
+    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 ) {
 
-        unless (exists $taxes{''}) {
-          my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
-          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_pkg, $class, $cust_pkg );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{$class} = $err_or_ref;
+      }
 
+      unless (exists $taxes{''}) {
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{''} = $err_or_ref;
       }
 
-    } else { # cust_main_county tax system
+    }
 
-      # We fetch taxes even if the customer is completely exempt,
-      # because we need to record that fact.
+    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+    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;
+        $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum;
+        # We need to create a separate $taxlisthash entry for each billpkgnum
+        # on the invoice, so that cust_bill_pkg_tax_location records will
+        # be linked correctly.
+
+        # $taxlisthash: keys are "setup", "recur", and usage classes.
+        # 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;
 
-      my @loc_keys = qw( district city county state country );
-      my $location = $cust_pkg->tax_location;
-      my %taxhash = map { $_ => $location->$_ } @loc_keys;
+      }
 
-      $taxhash{'taxclass'} = $part_pkg->taxclass;
+      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( $self ) ) {
+          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;
+          # we're calling taxline() right here?  wtf?
+          my $hashref_or_error = 
+            $tax_object->taxline( $localtaxlisthash{$tax},
+                                  'custnum'      => $self->custnum,
+                                  'invoice_time' => $invoice_time,
+                                );
+          return $hashref_or_error
+            unless ref($hashref_or_error);
+          
+          $taxlisthash->{ $totname } ||= [ $tot ];
+          push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
 
-      warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
+        }
+      }
+    }
 
-      my @taxes = (); # entries are cust_main_county objects
-      my %taxhash_elim = %taxhash;
-      my @elim = qw( district city county state );
-      do { 
+  } else {
 
-        #first try a match with taxclass
-        @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+    # INTERNAL TAX RATES (cust_main_county)
 
-        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
-          #then try a match without taxclass
-          my %no_taxclass = %taxhash_elim;
-          $no_taxclass{ 'taxclass' } = '';
-          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
-        }
+    # We fetch taxes even if the customer is completely exempt,
+    # because we need to record that fact.
 
-        $taxhash_elim{ shift(@elim) } = '';
+    my @loc_keys = qw( district city county state country );
+    my $location = $cust_pkg->tax_location;
+    my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
-      } while ( !scalar(@taxes) && scalar(@elim) );
+    $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      foreach (@taxes) {
-        # These could become cust_bill_pkg_tax_location records,
-        # or cust_tax_exempt_pkg.  We'll decide later.
-        #
-        # The most important thing here: record which charge is being taxed.
-        $_->set('billpkgnum',   $cust_bill_pkg->billpkgnum);
-        # also these, for historical reasons
-        $_->set('pkgnum',       $cust_pkg->pkgnum);
-        $_->set('locationnum',  $cust_pkg->tax_locationnum);
-      }
+    warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
 
-      $taxes{''} = [ @taxes ];
-      $taxes{'setup'} = [ @taxes ];
-      $taxes{'recur'} = [ @taxes ];
-      $taxes{$_} = [ @taxes ] foreach (@classes);
-
-      # # maybe eliminate this entirely, along with all the 0% records
-      # unless ( @taxes ) {
-      #   return
-      #     "fatal: can't find tax rate for state/county/country/taxclass ".
-      #     join('/', map $taxhash{$_}, qw(state county country taxclass) );
-      # }
-
-    } #if $conf->exists('enable_taxproducts') ...
-
-  } # if $self->payby eq 'COMP'
-
-  #what's this doing in the middle of _handle_taxes?  probably should split
-  #this into three parts above in _make_lines
-  $cust_bill_pkg->set_display(   part_pkg     => $part_pkg,
-                                 real_pkgpart => $real_pkgpart,
-                             );
-
-  my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
-  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;
-      $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum;
-      # We need to create a separate $taxlisthash entry for each billpkgnum
-      # on the invoice, so that cust_bill_pkg_tax_location records will
-      # be linked correctly.
-
-      # $taxlisthash: keys are "setup", "recur", and usage classes.
-      # 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;
+    my @taxes = (); # entries are cust_main_county objects
+    my %taxhash_elim = %taxhash;
+    my @elim = qw( district city county state );
+    do { 
 
-    }
+      #first try a match with taxclass
+      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
 
-    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');
+      if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+        #then try a match without taxclass
+        my %no_taxclass = %taxhash_elim;
+        $no_taxclass{ 'taxclass' } = '';
+        @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+      }
 
-      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
-        my $totname = ref( $tot ). ' '. $tot->taxnum;
+      $taxhash_elim{ shift(@elim) } = '';
 
-        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;
-        my $hashref_or_error = 
-          $tax_object->taxline( $localtaxlisthash{$tax},
-                                'custnum'      => $self->custnum,
-                                'invoice_time' => $invoice_time,
-                              );
-        return $hashref_or_error
-          unless ref($hashref_or_error);
-        
-        $taxlisthash->{ $totname } ||= [ $tot ];
-        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+    } while ( !scalar(@taxes) && scalar(@elim) );
 
-      }
+    foreach (@taxes) {
+      my $tax_id = 'cust_main_county '.$_->taxnum;
+      $taxlisthash->{$tax_id} ||= [ $_ ];
+      push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg;
     }
 
   }
-
   '';
 }
 
index 80063de..804969b 100644 (file)
@@ -757,19 +757,6 @@ sub fake_bop {
      return "Error: No error; test failure requested with fake_failure";
   }
 
-  #my $paybatch = '';
-  #if ( $payment_gateway->gatewaynum ) { # agent override
-  #  $paybatch = $payment_gateway->gatewaynum. '-';
-  #}
-  #
-  #$paybatch .= "$processor:". $transaction->authorization;
-  #
-  #$paybatch .= ':'. $transaction->order_number
-  #  if $transaction->can('order_number')
-  #  && length($transaction->order_number);
-
-  my $paybatch = 'FakeProcessor:54:32';
-
   my $cust_pay = new FS::cust_pay ( {
      'custnum'  => $self->custnum,
      'invnum'   => $options{'invnum'},
@@ -778,9 +765,11 @@ sub fake_bop {
      'payby'    => $bop_method2payby{$options{method}},
      #'payinfo'  => $payinfo,
      'payinfo'  => '4111111111111111',
-     'paybatch' => $paybatch,
      #'paydate'  => $paydate,
      'paydate'  => '2012-05-01',
+     'processor'      => 'FakeProcessor',
+     'auth'           => '54',
+     'order_number'   => '32',
   } );
   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
@@ -841,17 +830,8 @@ sub _realtime_bop_result {
 
   if ( $transaction->is_success() ) {
 
-    my $paybatch = '';
-    if ( $payment_gateway->gatewaynum ) { # agent override
-      $paybatch = $payment_gateway->gatewaynum. '-';
-    }
-
-    $paybatch .= $payment_gateway->gateway_module. ":".
-      $transaction->authorization;
-
-    $paybatch .= ':'. $transaction->order_number
-      if $transaction->can('order_number')
-      && length($transaction->order_number);
+    my $order_number = $transaction->order_number
+      if $transaction->can('order_number');
 
     my $cust_pay = new FS::cust_pay ( {
        'custnum'  => $self->custnum,
@@ -860,10 +840,14 @@ sub _realtime_bop_result {
        '_date'    => '',
        'payby'    => $cust_pay_pending->payby,
        'payinfo'  => $options{'payinfo'},
-       'paybatch' => $paybatch,
        'paydate'  => $cust_pay_pending->paydate,
        'pkgnum'   => $cust_pay_pending->pkgnum,
-       'discount_term' => $options{'discount_term'},
+       'discount_term'  => $options{'discount_term'},
+       'gatewaynum'     => ($payment_gateway->gatewaynum || ''),
+       'processor'      => $payment_gateway->gateway_module,
+       'auth'           => $transaction->authorization,
+       'order_number'   => $order_number || '',
+
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -1363,6 +1347,7 @@ sub realtime_refund_bop {
 
   my( $processor, $login, $password, @bop_options, $namespace ) ;
   my( $auth, $order_number ) = ( '', '', '' );
+  my $gatewaynum = '';
 
   if ( $options{'paynum'} ) {
 
@@ -1371,11 +1356,22 @@ sub realtime_refund_bop {
       or return "Unknown paynum $options{'paynum'}";
     $amount ||= $cust_pay->paid;
 
-    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
-      or return "Can't parse paybatch for paynum $options{'paynum'}: ".
-                $cust_pay->paybatch;
-    my $gatewaynum = '';
-    ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
+    if ( $cust_pay->get('processor') ) {
+      ($gatewaynum, $processor, $auth, $order_number) =
+      (
+        $cust_pay->gatewaynum,
+        $cust_pay->processor,
+        $cust_pay->auth,
+        $cust_pay->order_number,
+      );
+    } else {
+      # this payment wasn't upgraded, which probably means this won't work,
+      # but try it anyway
+      $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
+        or return "Can't parse paybatch for paynum $options{'paynum'}: ".
+                  $cust_pay->paybatch;
+      ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
+    }
 
     if ( $gatewaynum ) { #gateway for the payment to be refunded
 
@@ -1605,9 +1601,7 @@ sub realtime_refund_bop {
   return "$processor error: ". $refund->error_message
     unless $refund->is_success();
 
-  my $paybatch = "$processor:". $refund->authorization;
-  $paybatch .= ':'. $refund->order_number
-    if $refund->can('order_number') && $refund->order_number;
+  $order_number = $refund->order_number if $refund->can('order_number');
 
   while ( $cust_pay && $cust_pay->unapplied < $amount ) {
     my @cust_bill_pay = $cust_pay->cust_bill_pay;
@@ -1624,8 +1618,11 @@ sub realtime_refund_bop {
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
-    'paybatch' => $paybatch,
     'reason'   => $options{'reason'} || 'card or ACH refund',
+    'gatewaynum'    => $gatewaynum, # may be null
+    'processor'     => $processor,
+    'auth'          => $refund->authorization,
+    'order_number'  => $order_number,
   } );
   my $error = $cust_refund->insert;
   if ( $error ) {
index 87c1ca7..db6be75 100644 (file)
@@ -258,10 +258,15 @@ sub _list_sql {
 
 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
 
-Returns an hashref of a name and an amount of tax calculated for the 
-line items (L<FS::cust_bill_pkg> objects) in TAXABLES_ARRAYREF.  The line 
-items must come from the same invoice.  Returns a scalar error message 
-on error.
+Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
+line items, and returns a new L<FS::cust_bill_pkg> object representing
+the tax on them under this tax rate.
+
+This will have a pseudo-field, "cust_bill_pkg_tax_location", containing 
+an arrayref of L<FS::cust_bill_pkg_tax_location> objects.  Each of these 
+will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one
+of the taxable items.  All of these links must be resolved as the objects
+are inserted.
 
 In addition to calculating the tax for the line items, this will calculate
 any appropriate tax exemptions and attach them to the line items.
@@ -275,8 +280,7 @@ tax exemption limit if there is one.
 
 =cut
 
-# XXX this should just return a cust_bill_pkg object for the tax,
-# but that requires changing stuff in tax_rate.pm also.
+# XXX change tax_rate.pm to work like this
 
 sub taxline {
   my( $self, $taxables, %opt ) = @_;
@@ -294,7 +298,8 @@ sub taxline {
   my $dbh = dbh;
 
   my $name = $self->taxname || 'Tax';
-  my $amount = 0;
+  my $taxable_cents = 0;
+  my $tax_cents = 0;
 
   my $cust_bill = $taxables->[0]->cust_bill;
   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
@@ -325,6 +330,15 @@ sub taxline {
   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
     for @$taxables;
 
+  my $tax_item = FS::cust_bill_pkg->new({
+      'pkgnum'    => 0,
+      'recur'     => 0,
+      'sdate'     => '',
+      'edate'     => '',
+      'itemdesc'  => $name,
+  });
+  my @tax_location;
+
   foreach my $cust_bill_pkg (@$taxables) {
 
     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
@@ -472,37 +486,47 @@ sub taxline {
 
     $_->taxnum($self->taxnum) foreach @new_exemptions;
 
-    #if ( $cust_bill_pkg->billpkgnum ) {
-
-      #no, need to do this to e.g. calculate tax credit amounts
-      #die "tried to calculate tax exemptions on a previously billed line item\n";
-
-      # this is unnecessary
-#      foreach my $cust_tax_exempt_pkg (@new_exemptions) {
-#        my $error = $cust_tax_exempt_pkg->insert;
-#        if ( $error ) {
-#          $dbh->rollback if $oldAutoCommit;
-#          return "can't insert cust_tax_exempt_pkg: $error";
-#        }
-#      }
-    #}
-
     # attach them to the line item
     push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
     push @existing_exemptions, @new_exemptions;
 
-    # If we were smart, we'd also generate a cust_bill_pkg_tax_location 
-    # record at this point, but that would require redesigning more stuff.
     $taxable_charged = sprintf( "%.2f", $taxable_charged);
-
-    $amount += $taxable_charged * $self->tax / 100;
+    next if $taxable_charged == 0;
+
+    my $this_tax_cents = int($taxable_charged * $self->tax);
+    my $location = FS::cust_bill_pkg_tax_location->new({
+        'taxnum'      => $self->taxnum,
+        'taxtype'     => ref($self),
+        'cents'       => $this_tax_cents,
+        'taxable_cust_bill_pkg' => $cust_bill_pkg,
+        'tax_cust_bill_pkg'     => $tax_item,
+    });
+    push @tax_location, $location;
+
+    $taxable_cents += $taxable_charged;
+    $tax_cents += $this_tax_cents;
   } #foreach $cust_bill_pkg
-
-  return {
-    'name'   => $name,
-    'amount' => $amount,
-  };
-
+  
+  # now round and distribute
+  my $extra_cents = sprintf('%.2f', $taxable_cents * $self->tax / 100) * 100
+                    - $tax_cents;
+  if ( $extra_cents < 0 ) {
+    die "nonsense extra_cents value $extra_cents"; # because seriously, wtf
+  }
+  $tax_cents += $extra_cents;
+  my $i = 0;
+  foreach (@tax_location) { # 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));
+  }
+  $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
+  $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
+  
+  return $tax_item;
 }
 
 =back
index d28997c..4535aad 100644 (file)
@@ -100,7 +100,7 @@ Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
 
 =item paybatch
 
-text field for tracking card processing or other batch grouping
+obsolete text field for tracking card processing or other batch grouping
 
 =item payunique
 
@@ -130,11 +130,32 @@ The deposit account number.
 
 The teller number.
 
-=item pay_batch
+=item batchnum
 
 The number of the batch this payment came from (see L<FS::pay_batch>), 
 or null if it was processed through a realtime gateway or entered manually.
 
+=item gatewaynum
+
+The number of the realtime or batch gateway L<FS::payment_gateway>) this 
+payment was processed through.  Null if it was entered manually or processed
+by the "system default" gateway, which doesn't have a number.
+
+=item processor
+
+The name of the processor module (Business::OnlinePayment, ::BatchPayment, 
+or ::OnlineThirdPartyPayment subclass) used for this payment.  Slightly
+redundant with C<gatewaynum>.
+
+=item auth
+
+The authorization number returned by the credit card network.
+
+=item order_number
+
+The transaction ID returned by the gateway, if any.  This is usually what 
+you would use to initiate a void or refund of the payment.
+
 =back
 
 =head1 METHODS
@@ -878,6 +899,8 @@ sub _upgrade_data {  #class method
 
   warn "$me upgrading $class\n" if $DEBUG;
 
+  local $FS::payinfo_Mixin::ignore_masked_payinfo = 1;
+
   ##
   # otaker/ivan upgrade
   ##
@@ -1004,6 +1027,33 @@ sub _upgrade_data {  #class method
     if $error;
   }
 
+  ###
+  # migrate gateway info from the misused 'paybatch' field
+  ###
+
+  # not only cust_pay, but also voided and refunded payments
+  if (!FS::upgrade_journal->is_done('cust_pay__parse_paybatch')) {
+    # really inefficient, but again, only has to run once
+    foreach my $table (qw(cust_pay cust_pay_void cust_refund)) {
+      foreach my $object ( qsearch({
+            table     => $table,
+            extra_sql => "WHERE payby IN('CARD','CHEK') ".
+                         "AND paybatch IS NOT NULL",
+          }) )
+      {
+        my $parsed = $object->_parse_paybatch;
+        if (keys %$parsed) {
+          $object->set($_ => $parsed->{$_}) foreach keys %$parsed;
+          $object->set('paybatch', '');
+          my $error = $object->replace;
+          warn "error parsing CARD/CHEK paybatch fields on $object #".
+            $object->get($object->primary_key).":\n  $error\n"
+            if $error;
+        }
+      } #$object
+    } #$table
+    FS::upgrade_journal->set_done('cust_pay__parse_paybatch');
+  }
 }
 
 =back
index bebcfd4..42fc296 100644 (file)
@@ -1,7 +1,7 @@
 package FS::cust_pay_void; 
 
 use strict;
-use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
+use base qw( FS::otaker_Mixin FS::payinfo_transaction_Mixin FS::cust_main_Mixin
              FS::Record );
 use vars qw( @encrypted_fields $otaker_upgrade_kludge );
 use Business::CreditCard;
index 7df7a55..45a170b 100644 (file)
@@ -87,6 +87,11 @@ order taker (see L<FS::access_user>
 
 books closed flag, empty or `Y'
 
+=item gatewaynum, processor, auth, order_number
+
+Same as for L<FS::cust_pay>, but specifically the result of realtime 
+authorization of the refund.
+
 =back
 
 =head1 METHODS
index 7b713ef..9879a3a 100644 (file)
@@ -5,6 +5,8 @@ use Business::CreditCard;
 use FS::payby;
 use FS::Record qw(qsearch);
 
+use vars qw($ignore_masked_payinfo);
+
 =head1 NAME
 
 FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.  
@@ -207,17 +209,21 @@ sub payinfo_check {
 
   if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
     my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $self->payinfo($payinfo);
-    if ( $self->payinfo ) {
-      $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
-        or return "Illegal (mistyped?) credit card number (payinfo)";
-      $self->payinfo($1);
-      validate($self->payinfo) or return "Illegal credit card number";
-      return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token
-                                 && cardtype($self->payinfo) eq "Unknown";
+    if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
+      # allow it
     } else {
-      $self->payinfo('N/A'); #???
+      $payinfo =~ s/\D//g;
+      $self->payinfo($payinfo);
+      if ( $self->payinfo ) {
+        $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
+          or return "Illegal (mistyped?) credit card number (payinfo)";
+        $self->payinfo($1);
+        validate($self->payinfo) or return "Illegal credit card number";
+        return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token
+                                   && cardtype($self->payinfo) eq "Unknown";
+      } else {
+        $self->payinfo('N/A'); #???
+      }
     }
   } else {
     if ( $self->is_encrypted($self->payinfo) ) {
@@ -230,8 +236,6 @@ sub payinfo_check {
     }
   }
 
-  '';
-
 }
 
 =item payby_payinfo_pretty
index 19419de..093891e 100644 (file)
@@ -23,7 +23,8 @@ use vars qw(@ISA);
 =head1 DESCRIPTION
 
 This is a mixin class for records that represent transactions: that contain
-payinfo and paybatch.  Currently FS::cust_pay and FS::cust_refund
+payinfo and realtime result fields (gatewaynum, processor, authorization,
+order_number).  Currently FS::cust_pay, FS::cust_refund, and FS::cust_pay_void.
 
 =head1 METHODS
 
@@ -55,32 +56,8 @@ sub payby_name {
   }
 }
 
-=item gatewaynum
+# We keep _parse_paybatch just because the upgrade needs it.
 
-Returns a gatewaynum for the processing gateway.
-
-=item processor
-
-Returns a name for the processing gateway.
-
-=item authorization
-
-Returns a name for the processing gateway.
-
-=item order_number
-
-Returns a name for the processing gateway.
-
-=cut
-
-sub gatewaynum    { shift->_parse_paybatch->{'gatewaynum'}; }
-sub processor     { shift->_parse_paybatch->{'processor'}; }
-sub authorization { shift->_parse_paybatch->{'authorization'}; }
-sub order_number  { shift->_parse_paybatch->{'order_number'}; }
-
-#sucks that this stuff is in paybatch like this in the first place,
-#but at least other code can start to use new field names
-#(code nicked from FS::cust_main::realtime_refund_bop)
 sub _parse_paybatch {
   my $self = shift;
 
@@ -112,6 +89,33 @@ sub _parse_paybatch {
 
 }
 
+# because we can't actually name the field 'authorization' (reserved word)
+sub authorization {
+  my $self = shift;
+  $self->auth(@_);
+}
+
+=item payinfo_check
+
+Checks the validity of the realtime payment fields (gatewaynum, processor,
+auth, and order_number) as well as payby and payinfo
+
+=cut
+
+sub payinfo_check {
+  my $self = shift;
+
+  # All of these can be null, so in principle this could go in payinfo_Mixin.
+
+  $self->SUPER::payinfo_check()
+  || $self->ut_numbern('gatewaynum')
+  # not ut_foreign_keyn, it causes upgrades to fail
+  || $self->ut_alphan('processor')
+  || $self->ut_textn('auth')
+  || $self->ut_textn('order_number')
+  || '';
+}
+
 =back
 
 =head1 SEE ALSO
index 64905e1..837cc33 100644 (file)
@@ -12,7 +12,18 @@ use FS::Conf;
 use Text::CSV;
 
 my %opt;
-getopts('va:P:C:T:', \%opt);
+getopts('va:P:C:e:', \%opt);
+
+# Product codes that are subject to flat rate E911 charges.  For these 
+# products, the'quantity' field represents the number of lines.
+my @E911_CODES = ( 'V-HPBX', 'V-TRUNK' );
+
+# Map TAXNONVOICE/TAXVOICE to Freeside taxclass names
+my %TAXCLASSES = (
+  'TAXNONVOICE' => 'Other',
+  'TAXVOICE'   => 'VoIP',
+);
+  
 
 #$Net::SFTP::Foreign::debug = -1;
 sub HELP_MESSAGE { '
@@ -22,7 +33,7 @@ sub HELP_MESSAGE { '
         [ -a archivedir ]
         [ -P port ]
         [ -C category ]
-        [ -T taxclass ]
+        [ -e pkgpart ]
         freesideuser sftpuser@hostname[:path]
 ' }
 
@@ -30,12 +41,14 @@ my @fields = (
   'custnum',
   'date_desc',
   'quantity',
-  'amount',
+  'unit_price',
   'classname',
+  'taxclass',
 );
 
 my $user = shift or die &HELP_MESSAGE;
-adminsuidsetup $user;
+my $dbh = adminsuidsetup $user;
+$FS::UID::AutoCommit = 0;
 
 # for statistics
 my $num_charges = 0;
@@ -51,6 +64,16 @@ if ( $opt{a} ) {
     unless -w $opt{a};
 }
 
+my $e911_part_pkg;
+if ( $opt{e} ) {
+  $e911_part_pkg = FS::part_pkg->by_key($opt{e})
+    or die "E911 pkgpart $opt{e} not found.\n";
+
+  if ( $e911_part_pkg->base_recur > 0 or $e911_part_pkg->freq ) {
+    die "E911 pkgpart $opt{e} must be a one-time charge.\n";
+  }
+}
+
 my $categorynum = '';
 if ( $opt{C} ) {
   # find this category (don't auto-create it, it should exist already)
@@ -61,8 +84,6 @@ if ( $opt{C} ) {
   $categorynum = $category->categorynum;
 }
 
-my $taxclass = $opt{T} || '';
-
 #my $tmpdir = File::Temp->newdir();
 my $tmpdir = tempdir( CLEANUP => 1 ); #DIR=>somewhere?
 
@@ -88,7 +109,7 @@ my $sftp = Net::SFTP::Foreign->new(
   port      => $port,
   # for now we don't support passwords. use authorized_keys.
   timeout   => 30,
-  more      => ($opt{v} ? '-v' : ''),
+  #more      => ($opt{v} ? '-v' : ''),
 );
 die "failed to connect to '$sftpuser\@$host'\n(".$sftp->error.")\n"
   if $sftp->error;
@@ -100,6 +121,12 @@ if (!@$files) {
   print STDERR "No charge files found.\n" if $opt{v};
   exit(-1);
 }
+
+my %cust_main; # cache
+my %e911_qty; # custnum => sum of E911-subject quantity
+
+my %is_e911 = map {$_ => 1} @E911_CODES;
+
 FILE: foreach my $filename (@$files) {
   print STDERR "Retrieving $filename\n" if $opt{v};
   $sftp->get("$filename", "$tmpdir/$filename");
@@ -133,7 +160,7 @@ FILE: foreach my $filename (@$files) {
 
   open my $fh, "<$tmpdir/$filename";
   my $header = <$fh>;
-  if ($header !~ /^cust_id/) {
+  if ($header !~ /^"cust_id"/) {
     warn "warning: $filename has incorrect header row:\n$header\n";
     # but try anyway
   }
@@ -145,7 +172,8 @@ FILE: foreach my $filename (@$files) {
       next FILE;
     };
     @hash{@fields} = $csv->fields();
-    my $cust_main = FS::cust_main->by_key($hash{custnum});
+    my $cust_main = 
+      $cust_main{$hash{custnum}} ||= FS::cust_main->by_key($hash{custnum});
     if (!$cust_main) {
       warn "customer #$hash{custnum} not found\n";
       next;
@@ -153,13 +181,14 @@ FILE: foreach my $filename (@$files) {
     print STDERR "Found customer #$hash{custnum}: ".$cust_main->name."\n"
       if $opt{v};
 
+    my $amount = sprintf('%.2f',$hash{quantity} * $hash{unit_price});
     # construct arguments for $cust_main->charge
-    my %opt = (
-      amount      => $hash{amount},
+    my %charge_opt = (
+      amount      => $amount,
       quantity    => $hash{quantity},
       start_date  => $cust_main->next_bill_date,
       pkg         => $hash{date_desc},
-      taxclass    => $taxclass,
+      taxclass    => $TAXCLASSES{ $hash{taxclass} },
     );
     if (my $classname = $hash{classname}) {
       if (!exists($classnum_of{$classname}) ) {
@@ -182,11 +211,11 @@ FILE: foreach my $filename (@$files) {
 
         $classnum_of{$classname} = $pkg_class->classnum;
       }
-      $opt{classnum} = $classnum_of{$classname};
+      $charge_opt{classnum} = $classnum_of{$classname};
     }
-    print STDERR "  Charging $hash{amount}\n"
+    print STDERR "  Charging $hash{unit_price} * $hash{quantity}\n"
       if $opt{v};
-    my $error = $cust_main->charge(\%opt);
+    my $error = $cust_main->charge(\%charge_opt);
     if ($error) {
       warn "Error creating charge: $error" if $error;
       $num_errors++;
@@ -194,48 +223,94 @@ FILE: foreach my $filename (@$files) {
       $num_charges++;
       $sum_charges += $hash{amount};
     }
+
+    if ( $opt{e} and $is_e911{$hash{classname}} ) {
+      $e911_qty{$hash{custnum}} ||= 0;
+      $e911_qty{$hash{custnum}} += $hash{quantity};
+    }
   } #while $line
   close $fh;
 } #FILE
 
+# Order E911 packages
+my $num_e911 = 0;
+my $num_lines = 0;
+foreach my $custnum ( keys (%e911_qty) ) {
+  my $cust_main = $cust_main{$custnum};
+  my $quantity = $e911_qty{$custnum};
+  next if $quantity == 0;
+  my $cust_pkg = FS::cust_pkg->new({
+      pkgpart     => $opt{e},
+      custnum     => $custnum,
+      start_date  => $cust_main->next_bill_date,
+      quantity    => $quantity,
+  });
+  my $error = $cust_main->order_pkg({ cust_pkg => $cust_pkg });
+  if ( $error ) {
+    warn "Error creating e911 charge for customer $custnum: $error\n";
+    $num_errors++;
+  } else {
+    $num_e911++;
+    $num_lines += $quantity;
+  }
+}
+
+$dbh->commit;
+
 if ($opt{v}) {
   print STDERR "
 Finished!
   Processed files: @$files
   Created charges: $num_charges
   Sum of charges: \$".sprintf('%0.2f', $sum_charges)."
+  E911 charges: $num_e911
+  E911 lines: $num_lines
   Errors: $num_errors
 ";
 }
 
 =head1 NAME
 
-freeside-eftca-download - Retrieve payment batch responses from EFT Canada.
+freeside-ipifony-download - Download and import invoice items from IPifony.
 
 =head1 SYNOPSIS
 
-  freeside-eftca-download [ -v ] [ -a archivedir ] user
+      freeside-ipifony-download 
+        [ -v ]
+        [ -a archivedir ]
+        [ -P port ]
+        [ -C category ]
+        [ -T taxclass ]
+        [ -e pkgpart ]
+        freesideuser sftpuser@hostname[:path]
 
-=head1 DESCRIPTION
+=head1 REQUIRED PARAMETERS
 
-Command line tool to download returned payment reports from the EFT Canada 
-gateway and void the returned payments.  Uses the login and password from 
-'batchconfig-eft_canada'.
+I<freesideuser>: the Freeside user to run as.
 
--v: Be verbose.
+I<sftpuser>: the SFTP user to connect as.  The 'freeside' system user should 
+have an authorization key to connect as that user.
 
--a directory: Archive response files in the provided directory.
+I<hostname>: the SFTP server.
+
+=head1 OPTIONAL PARAMETERS
+
+-v: Be verbose.
 
-user: freeside username
+-a I<archivedir>: Save a copy of the downloaded file to I<archivedir>.
 
-=head1 BUGS
+-P I<port>: Connect to that TCP port.
 
-You need to manually SFTP to ftp.eftcanada.com from the freeside account 
-and accept their key before running this script.
+-C I<category>: The name of a package category to use when creating package
+classes.
 
-=head1 SEE ALSO
+-e I<pkgpart>: The pkgpart (L<FS::part_pkg>) to use for E911 charges.  A 
+package of this type will be ordered for each invoice that has E911-subject
+line items.  The 'quantity' field on this package will be set to the total 
+quantity of those line items.
 
-L<FS::pay_batch>
+The E911 package must be a one-time package (flat rate, no frequency, no 
+recurring fee) with setup fee equal to the fee per line.
 
 =cut
 
index 8c1f3db..49b74d3 100755 (executable)
@@ -90,8 +90,11 @@ my $notfound = 0;
 my $canceled = 0;
 print "Voiding ".scalar(@auths)." transactions:\n" if $opt{'v'};
 foreach my $authnum (@auths) {
-  my $paybatch = $gatewaynum . $processor . ':' . $authnum;
-  my $cust_pay = qsearchs('cust_pay', { paybatch => $paybatch } );
+  my $cust_pay = qsearchs('cust_pay', {
+     gatewaynum     => $gatewaynum,
+     processor      => $processor,
+     authorization  => $authnum,
+  });
   my $error;
   my $cancel_error;
   if($cust_pay) {
@@ -103,7 +106,11 @@ foreach my $authnum (@auths) {
     }
   }
   else {
-    my $cpv = qsearchs('cust_pay_void', { paybatch => $paybatch });
+    my $cpv = qsearchs('cust_pay_void', {
+       gatewaynum     => $gatewaynum,
+       processor      => $processor,
+       authorization  => $authnum,
+    });
     if($cpv) {
       $error = 'already voided '.time2str('%Y-%m-%d', $cpv->void_date) . 
         ' by ' . $cpv->otaker;
index 10c06eb..010678f 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -248,8 +248,8 @@ dev-perl-modules: perl-modules
 
 install-texmf: 
        install -D -o freeside -m 444 etc/longtable.sty \
-         ~freeside/texmf/tex/longtable.sty
-       texhash ~freeside
+         /usr/local/share/texmf/tex/latex/longtable.sty
+       texhash /usr/local/share/texmf
 
 install-init:
        #[ -e ${INIT_FILE} ] || install -o root -g ${INSTALLGROUP} -m 711 init.d/freeside-init ${INIT_FILE}
index 1ef69fd..656d5eb 100755 (executable)
   </TR>
 % } 
 
-%
-%  #false laziness w/FS/FS/cust_main::realtime_refund_bop
-%  if ( $cust_pay->paybatch =~ /^(\w+):(\w+)(:(\w+))?$/ ) {
-%    my ( $processor, $auth, $order_number ) = ( $1, $2, $4 );
-%  
-
-
     <TR>
-      <TD ALIGN="right">Processor</TD><TD BGCOLOR="#ffffff"><% $processor %></TD>
+      <TD ALIGN="right">Processor</TD>
+      <TD BGCOLOR="#ffffff"><% $cust_pay->processor %></TD>
     </TR>
 % if ( length($auth) ) { 
 
       <TR>
-        <TD ALIGN="right">Authorization</TD><TD BGCOLOR="#ffffff"><% $auth %></TD>
+        <TD ALIGN="right">Authorization</TD>
+        <TD BGCOLOR="#ffffff"><% $cust_pay->auth %></TD>
       </TR>
 % } 
-% if ( length($order_number) ) { 
+% if ( length($cust_pay->order_number) ) { 
 
       <TR>
-        <TD ALIGN="right">Order number</TD><TD BGCOLOR="#ffffff"><% $order_number %></TD>
+        <TD ALIGN="right">Order number</TD>
+        <TD BGCOLOR="#ffffff"><% $cust_pay->order_number %></TD>
       </TR>
 % } 
-% } 
+% }  #if $cust_pay
 
   </TABLE>
 % } 
index ce0ec32..a002fa1 100755 (executable)
@@ -57,6 +57,8 @@ my $new = new FS::cust_pay ( {
         bank depositor account teller
       )
   #} fields('cust_pay')
+  # gatewaynum, processor, auth, order_number
+  # are for realtime payments only, and can't be entered manually
 } );
 
 my @rights = ('Post payment');
index c334ae9..91bedf3 100644 (file)
@@ -13,6 +13,7 @@
                 'bottom_total' => 1,
                 'bottom_link'  => $bottom_link,
                 'agentnum'     => $agentnum,
+                'cust_classnum'=> \@cust_classnums,
              )
 %>
 <%init>
@@ -68,6 +69,9 @@ $title .= $sel_part_referral->referral.' '
 $title .= 'Sales Report (Gross)';
 $title .= ', average per customer package'  if $average_per_cust_pkg;
 
+my @cust_classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+$bottom_link .= "cust_classnum=$_;" foreach @cust_classnums;
+
 #classnum (here)
 # 0: all classes
 # not specified: empty class
@@ -188,6 +192,7 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' =>
         push @links, "$link;".
                      ($all_agent ? '' : "agentnum=$row_agentnum;").
                      ($all_part_referral ? '' : "refnum=$row_refnum;").
+                     (join('',map {"cust_classnum=$_;"} @cust_classnums)).
                      ($all_class ? '' : "classnum=$row_classnum;").
                      "distribute=$distribute;".
                      "use_override=$use_override;charges=$component;";
@@ -209,6 +214,7 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' =>
     my $component = join('', @components);
 
     my @row_params = (  'agentnum'              => $row_agentnum,
+                        'cust_classnum'         => \@cust_classnums,
                         'use_override'          => $use_override,
                         'average_per_cust_pkg'  => $average_per_cust_pkg,
                         'distribute'            => $distribute,
@@ -231,6 +237,8 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' =>
       $row_link .= ";refnum=".$sel_part_referral->refnum;
     }
 
+    $row_link .= ";cust_classnum=$_" foreach @cust_classnums;
+
     push @items, 'cust_bill_pkg';
     push @labels, mt('[_1] - Subtotal', $agent->agent);
     push @params, \@row_params;
index c736de6..939f18a 100644 (file)
@@ -37,6 +37,7 @@ Example:
     #optional
     'agentnum'        => $agentnum,
     'refnum'          => $refnum,
+    'cust_classnum'   => \@classnums,
     'nototal'         => 1,
     'graph_type'      => 'LinesPoints',
     'remove_empty'    => 1,
@@ -121,6 +122,7 @@ my %reportopts = (
       'projection'   => $opt{'projection'},
       'agentnum'     => $opt{'agentnum'},
       'refnum'       => $opt{'refnum'},
+      'cust_classnum'=> $opt{'cust_classnum'},
       'remove_empty' => $opt{'remove_empty'},
       'doublemonths' => $opt{'doublemonths'},
 );
index 166735f..9071fc7 100644 (file)
@@ -8,6 +8,7 @@
                 'links'        => \%link,
                 'agentnum'     => $agentnum,
                 'refnum'       => $refnum,
+                'cust_classnum'=> \@classnums,
                 'nototal'      => scalar($cgi->param('12mo')),
              )
 %>
@@ -25,6 +26,11 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
 }
 my $agentname = $agent ? $agent->agent.' ' : '';
 
+my @classnums;
+if ( $cgi->param('cust_classnum') ) {
+  @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+}
+
 my( $refnum, $part_referral ) = ('', '');
 if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   $refnum = $1;
@@ -93,6 +99,7 @@ $color{$_.'_12mo'} = $color{$_}
   foreach keys %color;
 
 my $ar = "agentnum=$agentnum;refnum=$refnum";
+$ar .= ";cust_classnum=$_" foreach @classnums;
 
 my %link = (
   'invoiced'   => "${p}search/cust_bill.html?$ar;",
index 4d16ff8..0fdbd89 100644 (file)
@@ -7,6 +7,7 @@
                 'colors'       => \%color,
                 'links'        => \%link,
                 'agentnum'     => $agentnum,
+                'cust_classnum'=> \@classnums,
                 'nototal'      => scalar($cgi->param('12mo')),
                 'daily'        => 1,
                 'start_day'    => $smday,
@@ -32,6 +33,11 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
 
 my $agentname = $agent ? $agent->agent.' ' : '';
 
+my @classnums;
+if ( $cgi->param('cust_classnum') ) {
+  @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+}
+
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 my ($ssec,$smin,$shour,$smday,$smon,$syear,$swday,$syday,$sisdst) 
     = localtime($beginning);
index 31792e8..251e7d3 100644 (file)
@@ -34,6 +34,12 @@ function enable_agent_totals(obj) {
   'onchange'      => 'enable_agent_totals',
 &>
 
+<& /elements/tr-select-cust_class.html,
+  'field'         => 'cust_classnum',
+  'label'         => 'Customer class',
+  'multiple'      => 1,
+&>
+
 <& /elements/tr-select-part_referral.html,
   'field'         => 'refnum',
   'label'         => 'Advertising source ',
index 97876c9..315d31b 100644 (file)
           )
 %>
 
+<& /elements/tr-select-cust_class.html,
+    'field'    => 'cust_classnum', # to avoid ambiguity in FS::Report::Table
+    'multiple' => 1
+&>
+
 <% include('/elements/tr-select-part_referral.html',
              'label'         => 'Advertising source ',
              'disable_empty' => 0,
index 8328199..a436d08 100644 (file)
           )
 %>
 
+<& /elements/tr-select-cust_class.html,
+    'field'    => 'cust_classnum',
+    'multiple' => 1,
+&>
+
 </TABLE>
 
 <BR><INPUT TYPE="submit" VALUE="Display">
index 406486a..3c0530e 100755 (executable)
@@ -97,6 +97,10 @@ if ( $cgi->param('invnum') =~ /^\s*(FS-)?(\d+)\s*$/ ) {
     $search{'refnum'} = $1;
   }
 
+  if ( $cgi->param('cust_classnum') ) {
+    $search{'cust_classnum'} = [ $cgi->param('cust_classnum') ];
+  }
+
   if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
     $search{'custnum'} = $1;
   }
index 22e9a67..79de749 100644 (file)
@@ -99,6 +99,13 @@ if ( $cgi->param('refnum') && $cgi->param('refnum') =~ /^(\d+)$/ ) {
   $title = $part_referral->referral. " $title";
 }
 
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  push @search, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
+
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 push @search, "cust_bill._date >= $beginning ",
               "cust_bill._date <= $ending";
index 817238d..1e67e93 100644 (file)
@@ -120,6 +120,8 @@ Filtering parameters:
 
 - refnum: Filter on customer reference source.
 
+- cust_classnum: Filter on customer class.
+
 - classnum: Filter on package class.
 
 - use_override: Apply "classnum" and "taxclass" filtering based on the 
@@ -258,6 +260,13 @@ if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.refnum = $1";
 }
 
+# cust_classnum
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  push @where, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
 # custnum
 if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.custnum = $1";
index 77b4860..1289ff7 100644 (file)
@@ -156,6 +156,11 @@ if ( @refnum ) {
   push @where, 'cust_main.refnum IN ('.join(',', @refnum).')';
 }
 
+my @cust_classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+if ( @cust_classnums ) {
+  push @where, 'cust_main.classnum IN ('.join(',', @cust_classnums).')';
+}
+
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
index 38f0349..f5d8fa1 100755 (executable)
@@ -103,6 +103,12 @@ if ( $cgi->param('refnum') && $cgi->param('refnum') =~ /^(\d+)$/ ) {
   $title = $part_referral->referral. " $title";
 }
 
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  push @search, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
 if ( $unapplied ) {
   push @search, FS::cust_credit->unapplied_sql . ' > 0';
 }
index 361c8ad..75138e9 100644 (file)
@@ -85,6 +85,13 @@ if ( $cgi->param('refnum') && $cgi->param('refnum') =~ /^(\d+)$/ ) {
   $title = $part_referral->referral. " $title";
 }
 
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  push @search, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
+
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 push @search, "cust_credit._date >= $beginning ",
               "cust_credit._date <= $ending";
index 0e9e24f..12c8962 100644 (file)
@@ -142,8 +142,7 @@ $title .=  $sel_part_referral->referral.' '
 
 $title .= 'Customer Accounting Summary Report';
 
-my @custs = ();
-@custs = qsearch('cust_main', {} );
+my @cust_classnums = grep /^\d+$/, $cgi->param('cust_classnum');
 
 my @items  = ('netsales', 'cashflow');
 my @params = ( [], [] );
@@ -167,6 +166,18 @@ my @custnames = ();
 my $status = $cgi->param('status');
 die "invalid status" unless $status =~ /^\w+|$/;
 
+my %search_hash;
+foreach (qw(agentnum refnum status)) {
+  if ( defined $cgi->param($_) ) {
+    $search_hash{$_} = $cgi->param($_);
+  }
+}
+$search_hash{'classnum'} = [ $cgi->param('cust_classnum') ] 
+  if $cgi->param('cust_classnum');
+
+my $query = FS::cust_main::Search->search(\%search_hash);
+my @custs = qsearch($query);
+
 foreach my $cust_main ( @custs ) {
   # XXX should do this in the qsearch
   next unless ($status eq '' || $status eq $cust_main->status); 
index c604111..eeef0c0 100755 (executable)
@@ -239,6 +239,12 @@ if ( $cgi->param('magic') ) {
       $title = $part_referral->referral. " $title";
     }
 
+    if ( $cgi->param('cust_classnum') ) {
+      my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+      push @search, 'cust_main.classnum IN('.join(',',@classnums).')'
+        if @classnums;
+    }
+
     if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
       push @search, "custnum = $1";
     }
@@ -324,6 +330,16 @@ if ( $cgi->param('magic') ) {
       push @search, "$table.payinfo = '$1'";
     }
 
+    if ( $cgi->param('ccpay') =~ /^([\w-:]+)$/ ) {
+      # I think that's all the characters we need to allow.
+      # To avoid confusion, this parameter searches both auth and order_number.
+      push @search, "($table.auth LIKE '$1%') OR ($table.order_number LIKE '$1%')";
+      push @fields, 'auth', 'order_number';
+      push @header, 'Auth #', 'Transaction #';
+      $align .= 'rr';
+
+    }
+
     if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
       push @search, "$table.usernum = $1";
     }
index a2b90b4..0e04ab0 100644 (file)
@@ -50,23 +50,48 @@ Examples:
   <SCRIPT TYPE="text/javascript">
   
     function payby_changed(what) {
-      if ( what.options[what.selectedIndex].value == 'BILL' ) {
-       document.getElementById('checkno_caption').style.color = '#000000';
-        what.form.payinfo.disabled = false;
-       what.form.payinfo.style.backgroundColor = '#ffffff';
+      if ( what.value == 'BILL' ) {
+        show('payinfo');
+        hide('ccpay');
+      } else if ( what.value.match(/^CARD|CHEK/) ) {
+        hide('payinfo');
+        show('ccpay');
       } else {
-       document.getElementById('checkno_caption').style.color = '#bbbbbb';
-        what.form.payinfo.disabled = true;
-       what.form.payinfo.style.backgroundColor = '#dddddd';
+        hide('payinfo');
+        hide('ccpay');
       }
     }
 
+    function show(what) {
+      document.getElementById(what+'_caption').style.color = '#000000';
+      document.getElementById(what).disabled = false;
+      document.getElementById(what).style.backgroundColor = '#ffffff';
+    }
+
+    function hide(what) {
+      document.getElementById(what+'_caption').style.color = '#bbbbbb';
+      document.getElementById(what).disabled = true;
+      document.getElementById(what).style.backgroundColor = '#dddddd';
+    }
+
+
+
   </SCRIPT>
 
   <TR>
-    <TD ALIGN="right"><FONT ID="checkno_caption" COLOR="#bbbbbb"><% mt('Check #:') |h %> </FONT></TD>
+    <TD ALIGN="right"><FONT ID="payinfo_caption" COLOR="#bbbbbb"><% mt('Check #:') |h %> </FONT></TD>
+    <TD>
+      <INPUT TYPE="text" ID="payinfo" NAME="payinfo" DISABLED STYLE="background-color: #dddddd">
+    </TD>
+  </TR>
+  <TR>
+    <TD ALIGN="right">
+      <FONT ID="ccpay_caption" COLOR="#bbbbbb">
+        <% mt('Transaction #') |h %>
+      </FONT>
+    </TD>
     <TD>
-      <INPUT TYPE="text" NAME="payinfo" DISABLED STYLE="background-color: #dddddd">
+      <INPUT TYPE="text" ID="ccpay" NAME="ccpay" DISABLED STYLE="background-color: #dddddd">
     </TD>
   </TR>
 
index ebac5a2..03d121d 100644 (file)
@@ -129,6 +129,13 @@ if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
   push @where, FS::cust_main->cust_status_sql . " = '$status'";
 }
 
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  $link .= ";cust_classnum=$_" foreach @classnums;
+  push @where, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
 my %total = ();
 my %total_legacy = ();
 foreach my $agentnum (@agentnums) {
index 4f6ee78..f121ef4 100644 (file)
      label => emt('Customer status'),
 &>
 
-<!-- customer
 <& /elements/tr-select-cust_class.html,
-     'label'        => emt('Class'),
+     'label'        => emt('Customer class'),
+     'field'        => 'cust_classnum',
      'multiple'     => 1,
      'pre_options'  => [ '' => emt('(none)') ],
      'all_selected' => 1,
 &>
--->
 
 <& /elements/tr-input-beginning_ending.html &>
 
index b4716d4..47478aa 100644 (file)
   'disable_empty' => 1,
 &>
 
+<& /elements/tr-select-cust_class.html,
+  'field'         => 'cust_classnum',
+  'multiple'      => 1,
+&>
+
 <& /elements/tr-select-pkg_class.html,
   'pre_options' => [ '' => 'all', '0' => '(empty class)' ],
   'disable_empty' => 1,
index 537abff..8206f34 100755 (executable)
                'label' => 'Customer Status'
     ) %>
 
+    <& /elements/tr-select-cust_class.html,
+                'label' => 'Customer Class',
+                'field' => 'cust_classnum',
+                'multiple' => 1,
+    &>
+
     <& /elements/tr-checkbox.html,
         'label' => 'Separate setup fees',
         'field' => 'setuprecur',
index 4743e2d..dfb2ea2 100644 (file)
@@ -33,6 +33,8 @@
   
   <& /elements/tr-select-cust_main-status.html,
       label => mt('Customer Status') &>
+  <& /elements/tr-select-cust_class.html,
+      label => mt('Customer Class'), field => 'cust_classnum', multiple => 1 &>
   <& /elements/tr-select.html,
       label => 'Invoice Status',
       field => 'mode',
index 02d514c..f61de05 100644 (file)
@@ -118,6 +118,12 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
 
+if ( $cgi->param('cust_classnum') ) {
+  my @classnums = grep /^\d+$/, $cgi->param('cust_classnum');
+  push @where, 'cust_main.classnum IN('.join(',',@classnums).')'
+    if @classnums;
+}
+
 # no pkgclass, no taxclass, no tax location...
 
 # unearned revenue mode
@@ -235,7 +241,8 @@ my $query = {
 my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
 my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
 
-my $money_char;
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
 
 sub money_sub {
   $conf ||= new FS::Conf;
index f9c8bc1..76a2488 100644 (file)
@@ -77,7 +77,7 @@
   <TD BGCOLOR="#FFFFFF"><B><% $cust_pay->payby_name %> #<% $cust_pay->paymask %></B></TD>
 </TR>
 
-% if ( $cust_pay->payby =~ /^(CARD|CHEK|LECB)$/ && $cust_pay->paybatch ) { 
+% if ( $cust_pay->payby =~ /^(CARD|CHEK|LECB)$/ && $cust_pay->processor ) { 
 
     <TR>
       <TD ALIGN="right"><% mt('Processor') |h %></TD>
@@ -86,7 +86,7 @@
 
     <TR>
       <TD ALIGN="right"><% mt('Authorization #') |h %></TD>
-      <TD BGCOLOR="#FFFFFF"><B><% $cust_pay->authorization %></B></TD>
+      <TD BGCOLOR="#FFFFFF"><B><% $cust_pay->auth %></B></TD>
     </TR>
 
 %   if ( $cust_pay->order_number ) {
index 996b4c0..3197615 100644 (file)
@@ -62,7 +62,7 @@
   <TD BGCOLOR="#FFFFFF"><B><% $cust_refund->payby_name %><% $cust_refund->paymask ? ' #'.$cust_refund->paymask : '' %></B></TD>
 </TR>
 
-% if ( $cust_refund->payby =~ /^(CARD|CHEK|LECB)$/ && $cust_refund->paybatch ) { 
+% if ( $cust_refund->payby =~ /^(CARD|CHEK|LECB)$/ && $cust_refund->processor ) { 
 
     <TR>
       <TD ALIGN="right"><% mt('Processor') |h %></TD>
@@ -71,7 +71,7 @@
 
     <TR>
       <TD ALIGN="right"><% mt('Authorization #') |h %></TD>
-      <TD BGCOLOR="#FFFFFF"><B><% $cust_refund->authorization %></B></TD>
+      <TD BGCOLOR="#FFFFFF"><B><% $cust_refund->auth %></B></TD>
     </TR>
 
 %   if ( $cust_refund->order_number ) {
index 14675b8..0da7d2e 100644 (file)
@@ -23,20 +23,38 @@ the same name, and should be single-valued fields.
 sub Prepare {
     my $self = shift;
     my $cfname = $self->Argument or return 0;
-    $self->{'inc_by'} = $self->TransactionObj->FirstCustomFieldValue($cfname) 
-                        || '';
-    return ( $self->{'inc_by'} =~ /^(\d+)$/ );
+    #RT::Logger->info('Accumulate::Prepare called on transaction '.
+    #                   $self->TransactionObj->Id." field $cfname");
+    my $TransObj = $self->TransactionObj;
+    my $TicketObj = $self->TicketObj;
+    if ( $TransObj->Type eq 'Create' and
+         !defined($TransObj->FirstCustomFieldValue($cfname)) ) {
+        # special case: we're creating a new ticket, and the initial value
+        # may have been set on the ticket instead of the transaction, so
+        # update the transaction to match
+        $self->{'obj'} = $TransObj;
+        $self->{'inc_by'} = $TicketObj->FirstCustomFieldValue($cfname);
+    } else {
+        # the usual case when updating an existing ticket
+        $self->{'obj'} = $TicketObj;
+        $self->{'inc_by'} = $TransObj->FirstCustomFieldValue($cfname) 
+                            || '';
+    }
+    return ( $self->{'inc_by'} =~ /^(\d+)$/ ); # else it's empty
 }
 
 sub Commit {
     my $self = shift;
     my $cfname = $self->Argument;
+    my $obj = $self->{'obj'};
     my $newval = $self->{'inc_by'} + 
-      ($self->TicketObj->FirstCustomFieldValue($cfname) || 0);
-    my ($val) = $self->TicketObj->AddCustomFieldValue(
-      Field => 'Support time',
-      Value => $newval,
-      RecordTransaction => 0,
+      ($obj->FirstCustomFieldValue($cfname) || 0);
+      #RT::Logger->info('Accumulate::Commit called on '.ref($obj).' '.
+      #                 $obj->Id." field $cfname");
+    my ($val) = $obj->AddCustomFieldValue(
+        Field => $cfname,
+        Value => $newval,
+        RecordTransaction => 0,
     );
     return $val;
 }
index 61b256e..b096286 100644 (file)
@@ -143,7 +143,7 @@ sub small_custview {
 
 }
 
-sub _FreesideURILabelLong {
+sub AsStringLong {
 
   my $self = shift;
 
@@ -161,30 +161,28 @@ sub _FreesideURILabelLong {
   } elsif ( $table eq 'cust_svc' ) {
 
     my $string = '';
-    # we now do this within the UI
-    #my $cust = $self->CustomerResolver;
-    #if ( $cust ) {
-    #  $string = $cust->AsStringLong;
-    #}
-    $string .= $self->AsString;
+    my $cust = $self->CustomerResolver;
+    if ( $cust ) {
+      # the customer's small_custview
+      $string = $cust->AsStringLong();
+    }
+    # + the service label and link
+    $string .= $self->ShortLink;
     return $string;
 
   } else {
 
-    return $self->_FreesideURILabel();
+    return $self->SUPER::AsStringLong;
 
   }
 
 }
 
-sub AsString {
+sub ShortLink {
+  # because I don't want AsString to sometimes return a hunk of HTML, but
+  # on the other hand AsStringLong does something specific.
   my $self = shift;
-  if ( $self->{'fstable'} eq 'cust_svc' ) {
-    return '<B><A HREF="' . $self->HREF . '">' . 
-          $self->_FreesideURILabel . '</A></B>';
-  } else {
-    $self->SUPER::AsString;
-  }
+  '<B><A HREF="'.$self->HREF.'">' . $self->_FreesideURILabel . '</A></B>';
 }
 
 sub CustomerResolver {
index e8aa69e..cc9956f 100644 (file)
 %       }
     </td>
     <td>
+%       if ( $resolver->URI =~ /cust_main/ ) {
       <% $resolver->AsStringLong |n %>
+%       } elsif ( $resolver->URI =~ /cust_svc/ ) {
+      <% $resolver->ShortLink |n %>
+%       }
     </td>
   </tr>
 %     }
index 175822f..f9b0133 100644 (file)
@@ -24,7 +24,7 @@ my %data = $m->comp('Customers', Ticket => $Ticket);
     <td class="value">
       <% $cust->AsStringLong |n %>
 %   foreach my $svc ( @{ $data{cust_svc}{$custnum} || [] } ) {
-      <% $svc->AsString |n %>
+      <% $svc->ShortLink |n %>
       <br>
 %   }
     </td>