Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Thu, 26 Jul 2012 21:05:08 +0000 (14:05 -0700)
committerIvan Kohler <ivan@freeside.biz>
Thu, 26 Jul 2012 21:05:08 +0000 (14:05 -0700)
51 files changed:
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/Template_Mixin.pm
FS/FS/access_user.pm
FS/FS/addr_block.pm
FS/FS/cust_bill.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Packages.pm
FS/FS/cust_pay.pm
FS/FS/cust_pay_batch.pm
FS/FS/cust_pkg.pm
FS/FS/cust_statement.pm
FS/FS/domain_record.pm
FS/FS/msg_template.pm
FS/FS/part_export/ez_prepaid.pm [new file with mode: 0644]
FS/FS/part_pkg/prepaid.pm
FS/FS/pay_batch.pm
FS/FS/payment_gateway.pm
FS/FS/quotation.pm
fs_selfservice/FS-SelfService/cgi/myaccount.html
fs_selfservice/FS-SelfService/cgi/provision_list.html
httemplate/edit/payment_gateway.html
httemplate/edit/prepay_credit.cgi
httemplate/edit/radius_group.html
httemplate/edit/svc_dsl.cgi
httemplate/elements/customer-table.html
httemplate/elements/menu.html
httemplate/elements/select-cust_pkg-status.html
httemplate/elements/tr-amount_fee.html [new file with mode: 0644]
httemplate/graph/elements/report.html
httemplate/misc/cancel_pkg.html
httemplate/misc/order_pkg.html
httemplate/misc/payment.cgi
httemplate/misc/process/cancel_pkg.html
httemplate/pref/pref-process.html
httemplate/pref/pref.html
httemplate/search/477partV.html
httemplate/search/cust_bill_pkg_referral.html [new file with mode: 0644]
httemplate/search/cust_main-zip.html
httemplate/search/elements/cust_pay_batch_top.html
httemplate/search/elements/cust_pay_or_refund.html
httemplate/search/elements/search-xls.html
httemplate/search/pay_batch.cgi
httemplate/search/report_cust_bill_pkg_referral.html [new file with mode: 0644]
httemplate/search/report_tax-xls.cgi
httemplate/view/cust_main/packages/status.html
httemplate/view/cust_main_statement-pdf.cgi
rt/lib/RT/EmailParser.pm
rt/lib/RT/Ticket.pm

index 0435945..37bbf6e 100644 (file)
@@ -2538,7 +2538,7 @@ and customer address. Include units.',
   {
     'key'         => 'manual_process-pkgpart',
     'section'     => 'billing',
-    'description' => 'Package to add to each manual credit card and ACH payments entered from the backend.  Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.',
+    'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend.  Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.',
     'type'        => 'select-part_pkg',
   },
 
@@ -2561,6 +2561,56 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'selfservice_process-pkgpart',
+    'section'     => 'billing',
+    'description' => 'Package to add to each manual credit card and ACH payment entered by the customer themselves in the self-service interface.  Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.',
+    'type'        => 'select-part_pkg',
+  },
+
+  {
+    'key'         => 'selfservice_process-display',
+    'section'     => 'billing',
+    'description' => 'When using selfservice_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.',
+    'type'        => 'select',
+    'select_hash' => [
+                       'add'      => 'Add fee to amount entered',
+                       'subtract' => 'Subtract fee from amount entered',
+                     ],
+  },
+
+  {
+    'key'         => 'selfservice_process-skip_first',
+    'section'     => 'billing',
+    'description' => "When using selfservice_process-pkgpart, omit the fee if it is the customer's first payment.",
+    'type'        => 'checkbox',
+  },
+
+  {
+    'key'         => 'suto_process-pkgpart',
+    'section'     => 'billing',
+    'description' => 'Package to add to each automatic credit card and ACH payment processed by billing events.  Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.',
+    'type'        => 'select-part_pkg',
+  },
+
+#  {
+#    'key'         => 'auto_process-display',
+#    'section'     => 'billing',
+#    'description' => 'When using auto_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.',
+#    'type'        => 'select',
+#    'select_hash' => [
+#                       'add'      => 'Add fee to amount entered',
+#                       'subtract' => 'Subtract fee from amount entered',
+#                     ],
+#  },
+
+  {
+    'key'         => 'auto_process-skip_first',
+    'section'     => 'billing',
+    'description' => "When using auto_process-pkgpart, omit the fee if it is the customer's first payment.",
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'allow_negative_charges',
     'section'     => 'billing',
     'description' => 'Allow negative charges.  Normally not used unless importing data from a legacy system that requires this.',
@@ -3475,6 +3525,13 @@ and customer address. Include units.',
     'select_enum' => [ 'approve', 'decline' ],
   },
 
+  {
+    'key'         => 'batch-errors_to',
+    'section'     => 'billing',
+    'description' => 'Email errors when processing batches to this address.  If unspecified, batch processing will stop immediately on error.',
+    'type'        => 'text',
+  },
+
   #lists could be auto-generated from pay_batch info
   {
     'key'         => 'batch-fixed_format-CARD',
@@ -3935,7 +3992,7 @@ and customer address. Include units.',
   {
     'key'         => 'disable_previous_balance',
     'section'     => 'invoicing',
-    'description' => 'Disable inclusion of previous balance, payment, and credit lines on invoices',
+    'description' => 'Disable inclusion of previous balance, payment, and credit lines on invoices.',
     'type'        => 'checkbox',
     'per_agent'   => 1,
   },
@@ -3962,6 +4019,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'previous_balance-show_on_statements',
+    'section'     => 'invoicing',
+    'description' => 'Show previous invoices on statements, without itemized charges.',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'balance_due_below_line',
     'section'     => 'invoicing',
     'description' => 'Place the balance due message below a line.  Only meaningful when when invoice_sections is false.',
@@ -5154,6 +5218,17 @@ and customer address. Include units.',
     'description' => 'If set, automatically log users out of the backoffice after this many minutes.',
     'type'       => 'text',
   },
+  
+  {
+    'key'         => 'spreadsheet_format',
+    'section'     => 'UI',
+    'description' => 'Default format for spreadsheet download.',
+    'type'        => 'select',
+    'select_hash' => [
+      'XLS' => 'XLS (Excel 97/2000/XP)',
+      'XLSX' => 'XLSX (Excel 2007+)',
+    ],
+  },
 
   { key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
index e26a4b7..51edd97 100644 (file)
@@ -91,6 +91,9 @@ if ( -e $addl_handler_use_file ) {
   use Text::CSV_XS;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
+  use Excel::Writer::XLSX;
+  use Excel::Writer::XLSX::Utility;
+
   use Business::CreditCard 0.30; #for mask-aware cardtype()
   use NetAddr::IP;
   use Net::Ping;
index 3fc26b0..ff40cd6 100644 (file)
@@ -1408,6 +1408,7 @@ sub tables_hashref {
         'depositor',  'varchar', 'NULL', $char_d, '', '',
         'account',    'varchar', 'NULL', 20,      '', '',
         'teller',     'varchar', 'NULL', 20,      '', '',
+        'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key
       ],
       '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' ] ],
@@ -1487,10 +1488,11 @@ sub tables_hashref {
       'columns' => [
         'batchnum', 'serial',     '', '', '', '', 
         'agentnum',    'int', 'NULL', '', '', '', 
-       'payby',      'char',     '',  4, '', '', # CARD/CHEK
+        'payby',      'char',     '',  4, '', '', # CARD/CHEK
         'status',     'char', 'NULL',  1, '', '', 
         'download',       @date_type,     '', '', 
         'upload',         @date_type,     '', '', 
+        'title',   'varchar', 'NULL',255, '', '',
       ],
       'primary_key' => 'batchnum',
       'unique' => [],
index d1bcec5..61cfccb 100644 (file)
@@ -12,6 +12,7 @@ use Text::Template 1.20;
 use File::Temp 0.14;
 use HTML::Entities;
 use Locale::Country;
+use Cwd;
 use FS::UID;
 use FS::Record qw( qsearch qsearchs );
 use FS::Misc qw( generate_ps generate_pdf );
@@ -133,7 +134,9 @@ sub print_latex {
   close $lh;
   $params{'logo_file'} = $lh->filename;
 
-  if( $conf->exists('invoice-barcode') && $self->can('invoice_barcode') ) {
+  if( $conf->exists('invoice-barcode') 
+        && $self->can('invoice_barcode')
+        && $self->invnum ) { # don't try to barcode statements
       my $png_file = $self->invoice_barcode($dir);
       my $eps_file = $png_file;
       $eps_file =~ s/\.png$/.eps/g;
@@ -699,6 +702,8 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
+  # Previous Charges section
+  # subtotal is the first return value from $self->previous
   my $previous_section = { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
@@ -801,11 +806,11 @@ sub print_generic {
     }
   }
 
-  unless (    $conf->exists('disable_previous_balance', $agentnum)
-           || $conf->exists('previous_balance-summary_only')
-           || ! $self->can('_items_previous')
-         )
-  {
+  # previous invoice balances in the Previous Charges section if there
+  # is one, otherwise in the main detail section
+  if ( $self->can('_items_previous') &&
+       $self->enable_previous &&
+       ! $conf->exists('previous_balance-summary_only') ) {
 
     warn "$me adding previous balances\n"
       if $DEBUG > 1;
@@ -836,9 +841,8 @@ sub print_generic {
     }
 
   }
-  
-  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
-    {
+
+  if ( @pr_cust_bill && $self->enable_previous ) {
     push @buf, ['','-----------'];
     push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
@@ -923,8 +927,10 @@ sub print_generic {
       }
       $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
                               $line_item->{'amount'};
-      $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
-                                 $line_item->{'unit_amount'};
+      if ( exists $line_item->{'unit_amount'} ) {
+        $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
+                                   $line_item->{'unit_amount'};
+      }
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
 
       $detail->{'sdate'} = $line_item->{'sdate'};
@@ -954,7 +960,9 @@ sub print_generic {
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
+  # create a major section for previous balance if we have major sections,
+  # or if previous_section is in summary form
+  if ( ( $multisection && $self->enable_previous )
     || $conf->exists('previous_balance-summary_only') )
   {
     unshift @sections, $previous_section if $pr_total;
@@ -1018,25 +1026,26 @@ sub print_generic {
 
   push @buf,['','-----------'];
   push @buf,[$self->mt( 
-              $conf->exists('disable_previous_balance', $agentnum) 
+              (!$self->enable_previous)
                ? 'Total Charges'
                : 'Total New Charges'
              ),
              $money_char. sprintf("%10.2f",$self->charged) ];
   push @buf,['',''];
 
+  # calculate total, possibly including total owed on previous
+  # invoices
   {
     my $total = {};
     my $item = 'Total';
     $item = $conf->config('previous_balance-exclude_from_total')
          || 'Total New Charges'
       if $conf->exists('previous_balance-exclude_from_total');
-    my $amount = $self->charged +
-                   ( $conf->exists('disable_previous_balance', $agentnum) ||
-                     $conf->exists('previous_balance-exclude_from_total')
-                     ? 0
-                     : $pr_total
-                   );
+    my $amount = $self->charged;
+    if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
+      $amount += $pr_total;
+    }
+
     $total->{'total_item'} = &$embolden_function($self->mt($item));
     $total->{'total_amount'} =
       &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
@@ -1058,12 +1067,13 @@ sub print_generic {
               ];
     push @buf,['',''];
   }
-  
-  unless (    $conf->exists('disable_previous_balance', $agentnum) 
-           || ! $self->can('_items_credits')
-           || ! $self->can('_items_payments')
-         )
-  {
+
+  # if we're showing previous invoices, also show previous
+  # credits and payments 
+  if ( $self->enable_previous 
+        and $self->can('_items_credits')
+        and $self->can('_items_payments') )
+    {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
index 5d5cc12..509cc09 100644 (file)
@@ -511,6 +511,42 @@ sub default_customer_view {
 
 }
 
+=item spreadsheet_format [ OVERRIDE ]
+
+Returns a hashref of this user's Excel spreadsheet download settings:
+'extension' (xls or xlsx), 'class' (Spreadsheet::WriteExcel or
+Excel::Writer::XLSX), and 'mime_type'.  If OVERRIDE is 'XLS' or 'XLSX',
+use that instead of the user's setting.
+
+=cut
+
+# is there a better place to put this?
+my %formats = (
+  XLS => {
+    extension => '.xls',
+    class => 'Spreadsheet::WriteExcel',
+    mime_type => 'application/vnd.ms-excel',
+  },
+  XLSX => {
+    extension => '.xlsx',
+    class => 'Excel::Writer::XLSX',
+    mime_type => # it's on wikipedia, it must be true
+      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  }
+);
+
+sub spreadsheet_format {
+  my $self = shift;
+  my $override = shift;
+
+  my $f =  $override
+        || $self->option('spreadsheet_format') 
+        || $conf->config('spreadsheet_format')
+        || 'XLS';
+
+  $formats{$f};
+}
+
 =item is_system_user
 
 Returns true if this user has the name of a known system account.  These 
index e00f587..686bdbd 100755 (executable)
@@ -223,43 +223,45 @@ sub cidr {
   $self->NetAddr->cidr;
 }
 
-=item free_addrs
+=item next_free_addr
 
 Returns a NetAddr::IP object corresponding to the first unassigned address 
 in the block (other than the network, broadcast, or gateway address).  If 
 there are no free addresses, returns nothing.  There are never free addresses
 when manual_flag is true.
 
-=item next_free_addr
-
-Returns a NetAddr::IP object for the first unassigned address in the block,
-or '' if there are none.
+There is no longer a method to return all free addresses in a block.
 
 =cut
 
-sub free_addrs {
+sub next_free_addr {
   my $self = shift;
+  my $selfaddr = $self->NetAddr;
 
   return if $self->manual_flag;
 
   my $conf = new FS::Conf;
   my @excludeaddr = $conf->config('exclude_ip_addr');
-  
+
   my %used = map { $_ => 1 }
   (
+    @excludeaddr,
+    $selfaddr->addr,
+    $selfaddr->network->addr,
+    $selfaddr->broadcast->addr,
     (map { $_->NetAddr->addr }
-      ($self,
-       qsearch('svc_broadband', { blocknum => $self->blocknum }))
+       qsearch('svc_broadband', { blocknum => $self->blocknum })
     ), @excludeaddr
   );
 
-  grep { !$used{$_->addr} } $self->NetAddr->hostenum;
-
-}
+  # just do a linear search of the block
+  my $freeaddr = $selfaddr->network + 1;
+  while ( $freeaddr < $selfaddr->broadcast ) {
+    return $freeaddr unless $used{ $freeaddr->addr };
+    $freeaddr++;
+  }
+  return;
 
-sub next_free_addr {
-  my $self = shift;
-  ($self->free_addrs, '')[0]
 }
 
 =item allocate -- deprecated
index 83748be..c3d48a6 100644 (file)
@@ -387,6 +387,19 @@ sub previous {
   $total, @cust_bill;
 }
 
+=item enable_previous
+
+Whether to show the 'Previous Charges' section when printing this invoice.
+The negation of the 'disable_previous_balance' config setting.
+
+=cut
+
+sub enable_previous {
+  my $self = shift;
+  my $agentnum = $self->cust_main->agentnum;
+  !$self->conf->exists('disable_previous_balance', $agentnum);
+}
+
 =item cust_bill_pkg
 
 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
index 78791dd..9602941 100644 (file)
@@ -2465,6 +2465,25 @@ Adds a payment for this invoice to the pending credit card batch (see
 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
 runs the payment using a realtime gateway.
 
+Options may include:
+
+B<amount>: the amount to be paid; defaults to the customer's balance minus
+any payments in transit.
+
+B<payby>: the payment method; defaults to cust_main.payby
+
+B<realtime>: runs this as a realtime payment instead of adding it to a 
+batch.  Deprecated.
+
+B<invnum>: sets cust_pay_batch.invnum.
+
+B<address1>, B<address2>, B<city>, B<state>, B<zip>, B<country>: sets 
+the billing address for the payment; defaults to the customer's billing
+location.
+
+B<payinfo>, B<paydate>, B<payname>: sets the payment account, expiration
+date, and name; defaults to those fields in cust_main.
+
 =cut
 
 sub batch_card {
@@ -2542,10 +2561,10 @@ sub batch_card {
     'state'    => $options{state}    || $loc->state,
     'zip'      => $options{zip}      || $loc->zip,
     'country'  => $options{country}  || $loc->country,
-    'payby'    => $options{payby}    || $loc->payby,
-    'payinfo'  => $options{payinfo}  || $loc->payinfo,
-    'exp'      => $options{paydate}  || $loc->paydate,
-    'payname'  => $options{payname}  || $loc->payname,
+    'payby'    => $options{payby}    || $self->payby,
+    'payinfo'  => $options{payinfo}  || $self->payinfo,
+    'exp'      => $options{paydate}  || $self->paydate,
+    'payname'  => $options{payname}  || $self->payname,
     'amount'   => $amount,                         # consolidating
   } );
   
index e7b9530..bab94c3 100644 (file)
@@ -968,7 +968,10 @@ sub _make_lines {
   my @recur_discounts = ();
   my $sdate;
   if (     ! $cust_pkg->start_date
-       and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill', 1) )
+       and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1)
+                               || ( $part_pkg->option('suspend_bill', 1) )
+                                     && ! $cust_pkg->option('no_suspend_bill',1)
+                                  )
        and
             ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) )
          || ( $part_pkg->plan eq 'voip_cdr'
index 957043a..11c13e5 100644 (file)
@@ -412,7 +412,11 @@ sub billing_pkgs {
   my $self = shift;
   grep { my $part_pkg = $_->part_pkg;
          $part_pkg->freq ne '' && $part_pkg->freq ne '0'
-           && ( ! $_->susp || $part_pkg->option('suspend_bill', 1) );
+           && ( ! $_->susp || $_->option('suspend_bill',1)
+                           || ( $part_pkg->option('suspend_bill', 1)
+                                  && ! $_->option('no_suspend_bill',1)
+                              )
+              );
        }
        $self->ncancelled_pkgs;
 }
index 2a2b9d0..c117386 100644 (file)
@@ -130,6 +130,11 @@ The deposit account number.
 
 The teller number.
 
+=item pay_batch
+
+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.
+
 =back
 
 =head1 METHODS
@@ -514,6 +519,7 @@ sub check {
     || $self->ut_alphan('depositor')
     || $self->ut_numbern('account')
     || $self->ut_numbern('teller')
+    || $self->ut_foreign_keyn('batchnum', 'pay_batch', 'batchnum')
     || $self->payinfo_check()
   ;
   return $error if $error;
@@ -983,6 +989,21 @@ sub _upgrade_data {  #class method
   $class->_upgrade_otaker(%opts);
   $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
 
+  ###
+  # migrate batchnums from the misused 'paybatch' field to 'batchnum'
+  ###
+  my @cust_pay = qsearch( {
+      'table'     => 'cust_pay',
+      'addl_from' => ' JOIN pay_batch ON cust_pay.paybatch = CAST(pay_batch.batchnum AS text) ',
+  } );
+  foreach my $cust_pay (@cust_pay) {
+    $cust_pay->set('batchnum' => $cust_pay->paybatch);
+    $cust_pay->set('paybatch' => '');
+    my $error = $cust_pay->replace;
+    warn "error setting batchnum on cust_pay #".$cust_pay->paynum.":\n  $error"
+    if $error;
+  }
+
 }
 
 =back
index 5f21ff4..9f2e9dd 100644 (file)
@@ -322,6 +322,7 @@ sub approve {
       'paid'      => $new->paid,
       '_date'     => $new->_date,
       'usernum'   => $new->usernum,
+      'batchnum'  => $new->batchnum,
     } );
   $error = $cust_pay->insert;
   if ( $error ) {
index 22559e9..aed99e5 100644 (file)
@@ -970,21 +970,25 @@ sub uncancel {
     }
 
     my $svc_error = $svc_x->insert;
-    if ( $svc_error && $options{svc_fatal} ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $svc_error;
-    } else {
-      my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
-      if ( $cust_svc ) {
-        my $cs_error = $cust_svc->delete;
-        if ( $cs_error ) {
-          $dbh->rollback if $oldAutoCommit;
-          return $cs_error;
+    if ( $svc_error ) {
+      if ( $options{svc_fatal} ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $svc_error;
+      } else {
+        push @svc_errors, $svc_error;
+        # is this necessary? svc_Common::insert already deletes the 
+        # cust_svc if inserting svc_x fails.
+        my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
+        if ( $cust_svc ) {
+          my $cs_error = $cust_svc->delete;
+          if ( $cs_error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return $cs_error;
+          }
         }
-      }
-    }
-    push @svc_errors, $svc_error if $svc_error;
-  }
+      } # svc_fatal
+    } # svc_error
+  } #foreach $h_cust_svc
 
   #these are pretty rare, but should handle them
   # - dsl_device (mac addresses)
@@ -1189,8 +1193,13 @@ sub suspend {
     $hash{'resume'} = $resume_date;
   }
 
+  $options{options} ||= {};
+
   my $new = new FS::cust_pkg ( \%hash );
-  $error = $new->replace( $self, options => { $self->options } );
+  $error = $new->replace( $self, options => { $self->options,
+                                              %{ $options{options} },
+                                            }
+                        );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -2700,7 +2709,7 @@ sub seconds_since_sqlradacct {
     grep {
       my $part_svc = $_->part_svc;
       $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
+        && scalar($part_svc->part_export_usage);
     } $self->cust_svc
   ) {
     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
@@ -2732,7 +2741,7 @@ sub attribute_since_sqlradacct {
     grep {
       my $part_svc = $_->part_svc;
       $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
+        && scalar($part_svc->part_export_usage);
     } $self->cust_svc
   ) {
     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
@@ -3590,20 +3599,40 @@ sub search {
                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
 
-  my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
+  my $select;
+  my $count_query;
+  if ( $params->{'select_zip5'} ) {
+    my $zip = 'cust_location.zip';
+
+    $select = "DISTINCT substr($zip,1,5) as zip";
+    $orderby = "ORDER BY substr($zip,1,5)";
+    $addl_from .= 'LEFT JOIN cust_location ON (
+                     cust_location.locationnum = COALESCE(
+                                                   cust_pkg.locationnum,
+                                                   cust_main.ship_locationnum,
+                                                   cust_main.bill_locationnum
+                                                 )
+                                              )';
+    $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
+  } else {
+    $select = join(', ',
+                         'cust_pkg.*',
+                         ( map "part_pkg.$_", qw( pkg freq ) ),
+                         'pkg_class.classname',
+                         'cust_main.custnum AS cust_main_custnum',
+                         FS::UI::Web::cust_sql_fields(
+                           $params->{'cust_fields'}
+                         ),
+                  );
+    $count_query = 'SELECT COUNT(*)';
+  }
+
+  $count_query .= " FROM cust_pkg $addl_from $extra_sql";
 
   my $sql_query = {
     'table'       => 'cust_pkg',
     'hashref'     => {},
-    'select'      => join(', ',
-                                'cust_pkg.*',
-                                ( map "part_pkg.$_", qw( pkg freq ) ),
-                                'pkg_class.classname',
-                                'cust_main.custnum AS cust_main_custnum',
-                                FS::UI::Web::cust_sql_fields(
-                                  $params->{'cust_fields'}
-                                ),
-                     ),
+    'select'      => $select,
     'extra_sql'   => $extra_sql,
     'order_by'    => $orderby,
     'addl_from'   => $addl_from,
index 45fae1c..9954b7b 100644 (file)
@@ -6,6 +6,8 @@ use FS::Record qw( dbh qsearch ); #qsearchs );
 use FS::cust_main;
 use FS::cust_bill;
 
+use List::Util qw( sum );
+
 =head1 NAME
 
 FS::cust_statement - Object methods for cust_statement records
@@ -61,8 +63,13 @@ Note that this stores the hash reference, not a distinct copy of the hash it
 points to.  You can ask the object for a copy with the I<hash> method.
 
 Pass "statementnum => 'ALL'" to create a temporary statement that includes 
-all of the customer's invoices.  This statement can't be inserted and won't
-set the statementnum field on any invoices.
+all of the customer's open invoices.  This statement can't be inserted and 
+won't set the statementnum field on any invoices.
+
+Pass "invnum => number" to create a temporary statement including only 
+the specified invoice.  This is functionally the same as the invoice itself,
+but will be rendered using the statement template and other 
+statement-specific options.
 
 =cut
 
@@ -170,13 +177,23 @@ Returns the associated invoices (cust_bill records) for this statement.
 sub cust_bill {
   my $self = shift;
   # we use it about a thousand times, let's cache it
-  $self->{Hash}->{cust_bill} ||= [
-    qsearch('cust_bill', { 
-        $self->statementnum eq 'ALL' ?
-          ('custnum' => $self->custnum) :
-          ('statementnum' => $self->statementnum)
-    } )
-  ];
+  if ( !exists($self->{Hash}->{cust_bill}) ) {
+    my @cust_bill;
+    if ( $self->invnum && $self->invnum =~ /^\d+$/ ) {
+      # one specific invoice
+      @cust_bill = FS::cust_bill->by_key($self->invnum)
+        or die "unknown invnum '".$self->invnum."'";
+      $self->set('custnum' => $cust_bill[0]->custnum);
+    } elsif ( $self->statementnum eq 'ALL' ) {
+      # all open invoices
+      @cust_bill = $self->cust_main->open_cust_bill;
+    } else {
+      @cust_bill = qsearch('cust_bill',
+        { statementnum => $self->statementnum }
+      );
+    }
+    $self->{Hash}->{cust_bill} = \@cust_bill;
+  }
 
   @{ $self->{Hash}->{cust_bill} }
 }
@@ -266,9 +283,20 @@ sub tax     { shift->_total('tax',     @_); }
 sub charged { shift->_total('charged', @_); }
 sub owed    { shift->_total('owed',    @_); }
 
-#don't show previous info
+sub enable_previous {
+  my $self = shift;
+  $self->conf->exists('previous_balance-show_on_statements');
+}
+
 sub previous {
-  ( 0 ); # 0, empty list
+  my $self = shift;
+  if ( $self->enable_previous ) {
+    my @previous = grep { $_->_date < ($self->cust_bill)[0]->_date }
+      $self->cust_main->open_cust_bill;
+    return(sum(map {$_->owed} @previous), @previous);
+  } else {
+    return 0;
+  }
 }
 
 =back
index 8d767d5..cd881ae 100644 (file)
@@ -3,8 +3,7 @@ package FS::domain_record;
 use strict;
 use vars qw( @ISA $noserial_hack $DEBUG $me );
 use FS::Conf;
-#use FS::Record qw( qsearch qsearchs );
-use FS::Record qw( qsearchs dbh );
+use FS::Record qw( qsearchs dbh ); #qsearch
 use FS::svc_domain;
 use FS::svc_www;
 
index ffb4f52..cac7fe5 100644 (file)
@@ -678,10 +678,20 @@ sub _upgrade_data {
     if ( $msg_template->subject || $msg_template->body ) {
       # create new default content
       my %content;
-      foreach ('subject','body') {
-        $content{$_} = $msg_template->$_;
-        $msg_template->setfield($_, '');
+      $content{subject} = $msg_template->subject;
+      $msg_template->set('subject', '');
+
+      # work around obscure Pg/DBD bug
+      # https://rt.cpan.org/Public/Bug/Display.html?id=60200
+      # (though the right fix is to upgrade DBD)
+      my $body = $msg_template->body;
+      if ( $body =~ /^x([0-9a-f]+)$/ ) {
+        # there should be no real message templates that look like that
+        warn "converting template body to TEXT\n";
+        $body = pack('H*', $1);
       }
+      $content{body} = $body;
+      $msg_template->set('body', '');
 
       my $error = $msg_template->replace(%content);
       die $error if $error;
diff --git a/FS/FS/part_export/ez_prepaid.pm b/FS/FS/part_export/ez_prepaid.pm
new file mode 100644 (file)
index 0000000..d171eb1
--- /dev/null
@@ -0,0 +1,183 @@
+package FS::part_export::ez_prepaid;
+
+use base qw( FS::part_export );
+
+use strict;
+use vars qw(@ISA %info $version $replace_ok_kludge $product_info);
+use Tie::IxHash;
+use FS::Record qw( qsearchs );
+use FS::svc_external;
+use SOAP::Lite;
+use XML::Simple qw( xml_in );
+use Data::Dumper;
+
+$version = '01';
+
+my $product_info;
+my %language_id = ( English => 1, Spanish => 2 );
+
+tie my %options, 'Tie::IxHash',
+  'site_id'     => { label => 'Site ID' },
+  'clerk_id'    => { label => 'Clerk ID' },
+#  'product_id'  => { label => 'Product ID' }, use the 'title' field
+#  'amount'      => { label => 'Purchase amount' },
+  'language'    => { label => 'Language',
+                     type  => 'select',
+                     options => [ 'English', 'Spanish' ],
+                    },
+
+  'debug'       => { label => 'Debug level',
+                     type  => 'select', options => [0, 1, 2 ] },
+;
+
+%info = (
+  'svc'     => 'svc_external',
+  'desc'    => 'Purchase EZ-Prepaid PIN',
+  'options' => \%options,
+  'notes'   => <<'END'
+<P>Export to the EZ-Prepaid PIN purchase service.  If the purchase is allowed,
+the PIN will be stored as svc_external.id.</P>
+<P>svc_external.title must contain the product ID, and should be set as a fixed
+field in the service definition.  For a list of product IDs, see the 
+"Merchant Info" tab in the EZ Prepaid reseller portal.</P>
+END
+  );
+
+$replace_ok_kludge = 0;
+
+sub _export_insert {
+  my ($self, $svc_external) = @_;
+
+  # the name on the certificate is 'debisys.com', for some reason
+  local $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME}=0;
+
+  my $pin = eval { $self->ez_prepaid_PinDistSale( $svc_external->title ) };
+  return $@ if $@;
+
+  local($replace_ok_kludge) = 1;
+  $svc_external->set('id', $pin);
+  $svc_external->replace;
+}
+
+sub _export_replace {
+  $replace_ok_kludge ? '' : "can't change PIN after purchase";
+}
+
+sub _export_delete {
+  "can't delete PIN after purchase";
+}
+
+# possibly options at some point to relate these to agentnum/usernum
+sub site_id { $_[0]->option('site_id') }
+
+sub clerk_id { $_[0]->option('clerk_id') }
+
+sub ez_prepaid_PinDistSale {
+  my $self = shift;
+  my $product_id = shift;
+  $self->ez_prepaid_init; # populate product ID cache
+  my $info = $product_info->{$product_id};
+  if ( $info ) {
+    if ( $self->option('debug') ) {
+      warn "Purchasing PIN product #$product_id:\n" .
+            $info->{Description}."\n".
+            $info->{CurrencyCode} . ' ' .$info->{Amount}."\n";
+    }
+  } else { #no $info
+    die "Unknown PIN product #$product_id.\n";
+  }
+
+  my $response = $self->ez_prepaid_request(
+    'PinDistSale',
+    $version,
+    $self->site_id,
+    $self->clerk_id,
+    $product_id,
+    '', # AccountID, not used for PIN sale
+    $product_info->{$product_id}->{Amount},
+    $self->svcnum,
+    ($language_id{ $self->option('language') } || 1),
+  );
+  if ( $self->option('debug') ) {
+    warn Dumper($response);
+    # includes serial number and transaction ID, possibly useful
+    # (but we don't have a structured place to store it--maybe in 
+    # a customer note?)
+  }
+  $response->{Pin};
+}
+
+sub ez_prepaid_init {
+  # returns the SOAP client object
+  my $self = shift;
+  my $wsdl = 'https://webservice.ez-prepaid.com/soap/webServices.wsdl';
+
+  if ( $self->option('debug') >= 2 ) {
+    SOAP::Lite->import(+trace => [transport => \&log_transport ]);
+  }
+  if ( !$self->client ) {
+    $self->set(client => SOAP::Lite->new->service($wsdl));
+    # I don't know if this can happen, but better to bail out here
+    # than go into recursion.
+    die "Error creating SOAP client\n" if !$self->client;
+  }
+
+  if ( !defined($product_info) ) {
+    # for now we only support the 'PIN' type
+    my $response = $self->ez_prepaid_request(
+      'GetTransTypeList', $version, $self->site_id, '', '', '', ''
+    );
+    my %transtype = map { $_->{Description} => $_->{TransTypeId} }
+      @{ $response->{TransType} };
+
+    if ( !exists $transtype{PIN} ) {
+      warn "'PIN' transaction type not available.\n";
+      # or else your site ID is wrong
+      return;
+    }
+
+    $response = $self->ez_prepaid_request(
+      'GetProductList',
+      $version,
+      $self->option('site_id'),
+      $transtype{PIN},
+      '', #CarrierId
+      '', #CategoryId
+      '', #ProductId
+    );
+    $product_info = +{
+      map { $_->{ProductId} => $_ }
+      @{ $response->{Product} }
+    };
+  } #!defined $product_info
+}
+
+sub log_transport {
+  my $in = shift;
+  if ( UNIVERSAL::can($in, 'content') ) {
+    warn $in->content."\n";
+  }
+}
+
+my @ForceArray = qw(TransType Product); # add others as needed
+sub ez_prepaid_request {
+  my $self = shift;
+  # takes a method name and param list,
+  # returns a hashref containing the unpacked response
+  # or dies on error
+  
+  $self->ez_prepaid_init if !$self->client;
+
+  my $method = shift;
+  my $xml = $self->client->$method(@_);
+  # All of their response data types are one part, a string, containing 
+  # an encoded XML structure, containing the fields described in the docs.
+  my $response = xml_in($xml, ForceArray => \@ForceArray);
+  if ( exists($response->{ResponseCode}) && $response->{ResponseCode} > 0 ) {
+    die "[$method] ".$response->{ResponseMessage};
+  }
+  $response;
+}
+
+1;
index 407343b..50f908c 100644 (file)
@@ -23,7 +23,7 @@ tie my %overlimit_action, 'Tie::IxHash',
   'shortname' => 'Prepaid, no automatic cycle',
   'inherit_fields' => [ 'usage_Mixin', 'global_Mixin' ],
   'fields' => {
-    'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid preiod',
+    'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid period',
                         'type' => 'select',
                        'select_options' => \%recur_action,
                      },
index 4f223e1..813d096 100644 (file)
@@ -12,6 +12,8 @@ use Date::Parse qw(str2time);
 use Business::CreditCard qw(cardtype);
 use Scalar::Util 'blessed';
 use IO::Scalar;
+use FS::Misc qw(send_email); # for error notification
+use List::Util qw(sum);
 
 @ISA = qw(FS::Record);
 
@@ -49,10 +51,14 @@ from FS::Record.  The following fields are currently supported:
 
 =item status - O (Open), I (In-transit), or R (Resolved)
 
-=item download - 
+=item download - time when the batch was first downloaded
 
-=item upload - 
+=item upload - time when the batch was first uploaded
 
+=item title - unique batch identifier
+
+For incoming batches, the combination of 'title', 'payby', and 'agentnum'
+must be unique.
 
 =back
 
@@ -118,9 +124,22 @@ sub check {
     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_alphan('title')
   ;
   return $error if $error;
 
+  if ( $self->title ) {
+    my @existing = 
+      grep { !$self->batchnum or $_->batchnum != $self->batchnum } 
+      qsearch('pay_batch', {
+          payby     => $self->payby,
+          agentnum  => $self->agentnum,
+          title     => $self->title,
+      });
+    return "Batch already exists as batchnum ".$existing[0]->batchnum
+      if @existing;
+  }
+
   $self->SUPER::check;
 }
 
@@ -225,11 +244,6 @@ sub import_results {
   my $job = $param->{'job'};
   $job->update_statustext(0) if $job;
 
-  my $gateway = $param->{'gateway'};
-  if ( $gateway ) {
-    return $self->import_from_gateway($gateway, 'file' => $fh, 'job' => $job);
-  }
-
   my $format = $param->{'format'};
   my $info = $import_info{$format}
     or die "unknown format $format";
@@ -444,9 +458,6 @@ sub process_import_results {
   my $param = thaw(decode_base64(shift));
   $param->{'job'} = $job;
   warn Dumper($param) if $DEBUG;
-  my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
-  my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
-
   my $gatewaynum = delete $param->{'gatewaynum'};
   if ( $gatewaynum ) {
     $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
@@ -461,12 +472,20 @@ sub process_import_results {
         '<',
         "$dir/$file" )
       or die "unable to open '$file'.\n";
-  my $error = $batch->import_results($param);
+  
+  my $error;
+  if ( $param->{gateway} ) {
+    $error = FS::pay_batch->import_from_gateway(%$param);
+  } else {
+    my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
+    my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
+    $error = $batch->import_results($param);
+  }
   unlink $file;
   die $error if $error;
 }
 
-=item import_from_gateway GATEWAY [ OPTIONS ]
+=item import_from_gateway [ OPTIONS ]
 
 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
 and apply them.  GATEWAY must use the Business::BatchPayment namespace.
@@ -477,15 +496,16 @@ or declined payment can have its status changed by a later import.
 
 OPTIONS may include:
 
-- file: a file name or handle to use as a data source.
+- gateway: the L<FS::payment_gateway>, required
+- filehandle: a file name or handle to use as a data source.
 - job: an L<FS::queue> object to update with progress messages.
 
 =cut
 
 sub import_from_gateway {
   my $class = shift;
-  my $gateway = shift;
   my %opt = @_;
+  my $gateway = $opt{'gateway'};
   my $conf = FS::Conf->new;
 
   # unavoidable duplication with import_batch, for now
@@ -508,121 +528,250 @@ sub import_from_gateway {
     unless eval { $gateway->isa('FS::payment_gateway') };
 
   my %proc_opt = (
-    'input' => $opt{'file'}, # will do nothing if it's empty
+    'input' => $opt{'filehandle'}, # will do nothing if it's empty
     # any other constructor options go here
   );
 
+  my @item_errors;
+  my $mail_on_error = $conf->config('batch-errors_to');
+  if ( $mail_on_error ) {
+    # construct error trap
+    $proc_opt{'on_parse_error'} = sub {
+      my ($self, $line, $error) = @_;
+      push @item_errors, "  '$line'\n$error";
+    };
+  }
+
   my $processor = $gateway->batch_processor(%proc_opt);
 
   my @batches = $processor->receive;
-  my $error;
+
   my $num = 0;
 
+  my $total_items = sum( map{$_->count} @batches);
+
   # whether to allow items to change status
   my $reconsider = $conf->exists('batch-reconsider');
 
   # mutex all affected batches
   my %pay_batch_for_update;
 
+  my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
+
   BATCH: foreach my $batch (@batches) {
+
+    my %incoming_batch = (
+      'CARD' => {},
+      'CHEK' => {},
+    );
+
     ITEM: foreach my $item ($batch->elements) {
-      # cust_pay_batch.paybatchnum should be in the 'tid' attribute
-      my $paybatchnum = $item->tid;
-      my $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
-      if (!$cust_pay_batch) {
-        # XXX for one-way batch protocol this needs to create new payments
-        $error = "unknown paybatchnum $paybatchnum";
-        last ITEM;
-      }
 
-      my $batchnum = $cust_pay_batch->batchnum;
-      if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
-        warn "batch ID ".$batch->batch_id.
-              " does not match batchnum ".$cust_pay_batch->batchnum."\n";
-      }
+      my $cust_pay_batch; # the new batch entry (with status)
+      my $pay_batch; # the freeside batch it belongs to
+      my $payby; # CARD or CHEK
+      my $error;
 
-      # lock the batch and check its status
-      my $pay_batch = FS::pay_batch->by_key($batchnum);
-      $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
-      if ( $pay_batch->status ne 'I' and !$reconsider ) {
-        $error = "batch $batchnum no longer in transit";
-        last ITEM;
-      }
+      # follow realtime gateway practice here
+      # though eventually this stuff should go into separate fields...
+      my $paybatch = $gateway->gatewaynum .  '-' .  $gateway->gateway_module .
+        ':' . $item->authorization .  ':' . $item->order_number;
+
+      if ( $batch->incoming ) {
+        # This is a one-way batch.
+        # Locate the customer, find an open batch correct for them,
+        # create a payment.  Don't bother creating a cust_pay_batch
+        # entry.
+        my $cust_main;
+        if ( defined($item->customer_id) 
+             and $item->customer_id =~ /^\d+$/ 
+             and $item->customer_id > 0 ) {
+
+          $cust_main = FS::cust_main->by_key($item->customer_id)
+                       || qsearchs('cust_main', 
+                         { 'agent_custid' => $item->customer_id }
+                       );
+          if ( !$cust_main ) {
+            push @item_errors, "Unknown customer_id ".$item->customer_id;
+            next ITEM;
+          }
+        }
+        else {
+          push @item_errors, "Illegal customer_id '".$item->customer_id."'";
+          next ITEM;
+        }
+        # it may also make sense to allow selecting the customer by 
+        # invoice_number, but no modules currently work that way
+
+        $payby = $bop2payby{ $item->payment_type };
+        my $agentnum = '';
+        $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
+
+        # create a batch if necessary
+        $pay_batch = $incoming_batch{$payby}->{$agentnum} ||= 
+          FS::pay_batch->new({
+              status    => 'R', # pre-resolve it
+              payby     => $payby,
+              agentnum  => $agentnum,
+              upload    => time,
+              title     => $batch->batch_id,
+          });
+        if ( !$pay_batch->batchnum ) {
+          $error = $pay_batch->insert;
+          die $error if $error; # can't do anything if this fails
+        }
+
+        if ( !$item->approved ) {
+          $error ||= "payment rejected - ".$item->error_message;
+        }
+        if ( !defined($item->amount) or $item->amount <= 0 ) {
+          $error ||= "no amount in item $num";
+        }
+
+        my $payinfo;
+        if ( $item->check_number ) {
+          $payby = 'BILL'; # right?
+          $payinfo = $item->check_number;
+        } elsif ( $item->assigned_token ) {
+          $payinfo = $item->assigned_token;
+        }
+        # create the payment
+        my $cust_pay = FS::cust_pay->new(
+          {
+            custnum     => $cust_main->custnum,
+            _date       => $item->payment_date->epoch,
+            paid        => sprintf('%.2f',$item->amount),
+            payby       => $payby,
+            invnum      => $item->invoice_number,
+            batchnum    => $pay_batch->batchnum,
+            paybatch    => $paybatch,
+            payinfo     => $payinfo,
+          }
+        );
+        $error ||= $cust_pay->insert;
+        eval { $cust_main->apply_payments };
+        $error ||= $@;
 
-      if ( $cust_pay_batch->status ) {
-        my $new_status = $item->approved ? 'approved' : 'declined';
-        if ( lc( $cust_pay_batch->status ) eq $new_status ) {
-          # already imported with this status, so don't touch
+        if ( $error ) {
+          push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
+        }
+
+      } else {
+        # This is a request/reply batch.
+        # Locate the request (the 'tid' attribute is the paybatchnum).
+        my $paybatchnum = $item->tid;
+        $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
+        if (!$cust_pay_batch) {
+          push @item_errors, "paybatchnum $paybatchnum not found";
           next ITEM;
         }
-        elsif ( !$reconsider ) {
-          # then we're not allowed to change its status, so bail out
-          $error = "paybatchnum ".$item->tid.
+        $payby = $cust_pay_batch->payby;
+
+        my $batchnum = $cust_pay_batch->batchnum;
+        if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
+          warn "batch ID ".$batch->batch_id.
+                " does not match batchnum ".$cust_pay_batch->batchnum."\n";
+        }
+
+        # lock the batch and check its status
+        $pay_batch = FS::pay_batch->by_key($batchnum);
+        $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
+        if ( $pay_batch->status ne 'I' and !$reconsider ) {
+          $error = "batch $batchnum no longer in transit";
+        }
+
+        if ( $cust_pay_batch->status ) {
+          my $new_status = $item->approved ? 'approved' : 'declined';
+          if ( lc( $cust_pay_batch->status ) eq $new_status ) {
+            # already imported with this status, so don't touch
+            next ITEM;
+          }
+          elsif ( !$reconsider ) {
+            # then we're not allowed to change its status, so bail out
+            $error = "paybatchnum ".$item->tid.
             " already resolved with status '". $cust_pay_batch->status . "'";
-          last ITEM;
+          }
         }
-      }
 
-      # create a new cust_pay_batch with whatever information we got back
-      my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
-      my $new_payinfo;
-      # update payinfo, if needed
-      if ( $item->assigned_token ) {
-        $new_payinfo = $item->assigned_token;
-      } elsif ( $cust_pay_batch->payby eq 'CARD' ) {
-        $new_payinfo = $item->card_number if $item->card_number;
-      } else { #$cust_pay_batch->payby eq 'CHEK'
-        $new_payinfo = $item->account_number . '@' . $item->routing_code
-          if $item->account_number;
-      }
-      $new_cust_pay_batch->payinfo($new_payinfo) if $new_payinfo;
+        if ( $error ) {        
+          push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+          next ITEM;
+        }
 
-      # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
-      # paid, if the batch says it's different from the amount requested
-      if ( defined $item->amount ) {
-        $new_cust_pay_batch->paid($item->amount);
-      } else {
-        $new_cust_pay_batch->paid($cust_pay_batch->amount);
-      }
+        my $new_payinfo;
+        # update payinfo, if needed
+        if ( $item->assigned_token ) {
+          $new_payinfo = $item->assigned_token;
+        } elsif ( $payby eq 'CARD' ) {
+          $new_payinfo = $item->card_number if $item->card_number;
+        } else { #$payby eq 'CHEK'
+          $new_payinfo = $item->account_number . '@' . $item->routing_code
+            if $item->account_number;
+        }
+        $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
+
+        # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
+        # paid, if the batch says it's different from the amount requested
+        if ( defined $item->amount ) {
+          $cust_pay_batch->set('paid', $item->amount);
+        } else {
+          $cust_pay_batch->set('paid', $cust_pay_batch->amount);
+        }
+
+        # set payment date to when it was processed
+        $cust_pay_batch->_date($item->payment_date->epoch)
+          if $item->payment_date;
+
+        # approval status
+        if ( $item->approved ) {
+          # follow Billing_Realtime format for paybatch
+          $error = $cust_pay_batch->approve($paybatch);
+          $total += $cust_pay_batch->paid;
+        }
+        else {
+          $error = $cust_pay_batch->decline($item->error_message);
+        }
+
+        if ( $error ) {        
+          push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+          next ITEM;
+        }
+      } # $batch->incoming
 
-      # set payment date to when it was processed
-      $new_cust_pay_batch->_date($item->payment_date->epoch)
-        if $item->payment_date;
-
-      # approval status
-      if ( $item->approved ) {
-        # follow Billing_Realtime format for paybatch
-        my $paybatch = $gateway->gatewaynum .
-          '-' .
-          $gateway->gateway_module .
-          ':' .
-          $item->authorization .
-          ':' .
-          $item->order_number;
-
-        $error = $new_cust_pay_batch->approve($paybatch);
-        $total += $new_cust_pay_batch->paid;
-      }
-      else {
-        $error = $new_cust_pay_batch->decline($item->error_message);
-      }
-      last ITEM if $error;
       $num++;
-      $job->update_statustext(int(100 * $num/( $batch->count + 1 ) ),
+      $job->update_statustext(int(100 * $num/( $total_items ) ),
         'Importing batch items')
-        if $job;
+      if $job;
+
     } #foreach $item
 
-    if ( $error ) {
+  } #foreach $batch (input batch, not pay_batch)
+
+  # Format an error message
+  if ( @item_errors ) {
+    my $error_text = join("\n\n", 
+      "Errors during batch import: ".scalar(@item_errors),
+      @item_errors
+    );
+    if ( $mail_on_error ) {
+      my $subject = "Batch import errors"; #?
+      my $body = "Import from gateway ".$gateway->label."\n".$error_text;
+      send_email(
+        to      => $mail_on_error,
+        from    => $conf->config('invoice_from'),
+        subject => $subject,
+        body    => $body,
+      );
+    } else {
+      # Bail out.
       $dbh->rollback if $oldAutoCommit;
-      return $error;
+      die $error_text;
     }
+  }
 
-  } #foreach $batch (input batch, not pay_batch)
-
-  # Auto-resolve
+  # Auto-resolve (with brute-force error handling)
   foreach my $pay_batch (values %pay_batch_for_update) {
-    $error = $pay_batch->try_to_resolve;
+    my $error = $pay_batch->try_to_resolve;
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -637,7 +786,7 @@ sub import_from_gateway {
 =item try_to_resolve
 
 Resolve this batch if possible.  A batch can be resolved if all of its
-entries have status.  If the system options 'batch-auto_resolve_days'
+entries have status.  If the system options 'batch-auto_resolve_days'
 and 'batch-auto_resolve_status' are set, and the batch's download date is
 at least (batch-auto_resolve_days) before the current time, then it can
 be auto-resolved; entries with no status will be approved or declined 
index fac7384..4a7585e 100644 (file)
@@ -219,7 +219,7 @@ Returns a semi-friendly label for the gateway.
 sub label {
   my $self = shift;
   $self->gatewaynum . ': ' . 
-  $self->gateway_username . '@' . 
+  ($self->gateway_username ? $self->gateway_username . '@' : '') . 
   $self->gateway_module
 }
 
index 0cfb11e..ccaa1c3 100644 (file)
@@ -150,6 +150,12 @@ sub cust_bill_pkg {
 
 =back
 
+=item enable_previous
+
+=cut
+
+sub enable_previous { 0 }
+
 =head1 BUGS
 
 =head1 SEE ALSO
index 5370f7c..9ab2622 100644 (file)
@@ -6,7 +6,7 @@ Hello <%= $name %>!<BR><BR>
 <%= include('small_custview') %>
 
 <BR>
-<%= if ( $access_pkgnum ) {
+<%= unless ( $access_pkgnum ) {
       $OUT .= qq!Balance: <B>\$$balance</B><BR><BR>!;
     }
     '';
index 22054e6..2a3a8e9 100644 (file)
@@ -10,7 +10,6 @@ foreach my $pkg (
          } @cust_pkg
     ) {
   my $susp = $pkg->{'susp'} || '';
-  warn $pkg->{'pkg'}. ' '.$susp."\n";
   my @pkg_actions = ( [ 'customer_change_pkg' => 'change' ] );
   push @pkg_actions, [ 'process_suspend_pkg' => 'suspend' ] 
     if $self_suspend_reason and !$susp;
index 2840df3..e5897b0 100644 (file)
@@ -19,8 +19,7 @@
 
 
 <SCRIPT TYPE="text/javascript">
-% my $json = JSON->new->canonical;
-  var modulesForNamespace = <% $json->encode(\%modules_for_namespace) %>;
+  var modulesForNamespace = <% to_json(\%modules_for_namespace, {canonical=>1}) %>;
   function changeNamespace(what) {
     var ns = what.value;
     var select_module = document.getElementById('gateway_module');
@@ -68,7 +67,6 @@ my %modules =  (
   'OpenECHO'              => 'Business::OnlinePayment',
   'PayConnect'            => 'Business::OnlinePayment',
   'PayflowPro'            => 'Business::OnlinePayment',
-  'Paymentech'            => 'Business::BatchPayment',
   'PaymenTech'            => 'Business::OnlinePayment',
   'PaymentsGateway'       => 'Business::OnlinePayment',
   'PayPal'                => 'Business::OnlinePayment',
@@ -90,6 +88,9 @@ my %modules =  (
   'VirtualNet'            => 'Business::OnlinePayment',
   'WesternACH'            => 'Business::OnlinePayment',
   'WorldPay'              => 'Business::OnlinePayment',
+
+  'KeyBank'               => 'Business::BatchPayment',
+  'Paymentech'            => 'Business::BatchPayment',
 );
 
 my %modules_for_namespace;
index f7a1b08..c03bbf9 100644 (file)
@@ -18,13 +18,11 @@ prepaid cards of
 
 characters each
 
-<BR>for <SELECT NAME="agentnum"><OPTION>(any agent)
-% foreach my $opt_agent ( qsearch('agent', { 'disabled' => '' } ) ) { 
+<BR>for 
 
-  <OPTION VALUE="<% $opt_agent->agentnum %>"<% $opt_agent->agentnum == $agentnum ? ' SELECTED' : '' %>><% $opt_agent->agent %>
-% } 
-
-</SELECT>
+<& /elements/select-agent.html,
+     'empty_label' => '(any agent)',
+&>
 
 <TABLE>
 <TR><TD>Value: 
index 0255611..0c99b4c 100644 (file)
@@ -8,6 +8,7 @@
     'attrnum'   => 'Attribute',
     'priority'  => 'Priority',
   },
+  'viewall_dir' => 'browse',
   'menubar' => \@menubar,
   'edit_callback' => $edit_callback,
   'error_callback' => $edit_callback,
index 1aeadb3..36345b9 100644 (file)
@@ -52,12 +52,24 @@ my $edit_cb = sub {
         elsif($export->exporttype eq 'ikano') {
             @fields = ( 'password', 'monitored', );
 
-            foreach my $hf ( keys %$ti_fields ) {
-                push @fields, {
-                    field => $hf,
-                    type => 'hidden',
-                    value => $svc_x->$hf,
-                } unless ( $hf eq 'password' || $hf eq 'monitored' );
+            if ( $svc_x->vendor_qual_id ) {
+              push @fields, { field => 'vendor_qual_id',
+                              type  => 'hidden',
+                              value => $svc_x->vendor_qual_id,
+                            };
+            } else {
+              push @fields, 'vendor_qual_id';
+            }
+
+            foreach my $hf (
+              grep { $_ !~ /^(password|monitored|vendor_qual_id)$/ }
+                keys %$ti_fields
+            ) {
+              push @fields, {
+                field => $hf,
+                type  => 'hidden',
+                value => $svc_x->$hf,
+              };
             }
         }
         # else add any other export-specific stuff here
index 79443dc..75e682d 100644 (file)
@@ -203,7 +203,11 @@ Example:
 
       var customerArrayArray = eval('(' + customers + ')') || [];
 
-      if ( customerArrayArray.length == 1 ) {
+      if ( customerArrayArray.length == 0 ) {
+
+        update_customer(searchrow, []);
+
+      } else if ( customerArrayArray.length == 1 ) {
 
         update_customer(searchrow, customerArrayArray[0]);
 % if ( $opt{custnum_update_callback} ) {
@@ -277,7 +281,11 @@ Example:
       custnum_obj.disabled = false;
       custnum_obj.style.backgroundColor = '#ffffff';
 
-      if ( customerArrayArray.length == 1 ) {
+      if ( customerArrayArray.length == 0 ) {
+
+        update_customer(searchrow, []);
+
+      } else if ( customerArrayArray.length == 1 ) {
 
         update_customer(searchrow, customerArrayArray[0]);
 % if ( $opt{custnum_update_callback} ) {
index 9383035..019afe9 100644 (file)
@@ -314,6 +314,7 @@ if($curuser->access_right('Financial reports')) {
     'Daily Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time_daily.html', 'Sales, credits and receipts (broken down by day) summary graph' ],
     'Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg.html', 'Sales report and graph (by agent, package class and/or date range)' ],
     'Rated Call Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg_detail.html', 'Sales report and graph (by agent, package class, usage class and/or date range)' ],
+    'Sales With Advertising Source' => [ $fsurl.'search/report_cust_bill_pkg_referral.html' ],
     'Employee Commission Report' => [ $fsurl.'search/report_employee_commission.html', '' ],
     'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ],
     'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ],
index ec37eaf..2114c07 100644 (file)
@@ -3,7 +3,9 @@
         <% $onchange %>
 >
 
+% if ( !$opt{'disable_empty'} ) {
   <OPTION VALUE="">all
+% }
 
 % foreach my $option ( @{ $opt{'statuses'} } ) { 
 
diff --git a/httemplate/elements/tr-amount_fee.html b/httemplate/elements/tr-amount_fee.html
new file mode 100644 (file)
index 0000000..a1a9e34
--- /dev/null
@@ -0,0 +1,98 @@
+  <TR>
+    <TH ALIGN="right"><% mt('Payment amount') |h %></TH>
+    <TD COLSPAN=7>
+      <TABLE><TR><TD BGCOLOR="#ffffff">
+        <% $money_char %><INPUT NAME     = "amount"
+                                ID       = "amount"
+                                TYPE     = "text"
+                                VALUE    = "<% $amount %>"
+                                SIZE     = 8
+                                STYLE    = "text-align:right;"
+%                               if ( $fee ) {
+                                  onChange   = "amount_changed(this)"
+                                  onKeyDown  = "amount_changed(this)"
+                                  onKeyUp    = "amount_changed(this)"
+                                  onKeyPress = "amount_changed(this)"
+%                               }
+                         >
+      </TD><TD BGCOLOR="#cccccc">
+%        if ( $fee ) {
+           <INPUT TYPE="hidden" NAME="fee_pkgpart" VALUE="<% $fee_pkg->pkgpart %>">
+           <INPUT TYPE="hidden" NAME="fee" VALUE="<% $fee_display eq 'add' ? $fee : '' %>">
+           <B><FONT SIZE='+1'><% $fee_op %></FONT>
+              <% $money_char . $fee %>
+           </B>
+           <% $fee_pkg->pkg |h %>
+           <B><FONT SIZE='+1'>=</FONT></B>
+      </TD><TD ID="ajax_total_cell" BGCOLOR="#dddddd" STYLE="border:1px solid blue">
+           <FONT SIZE="+1"><% length($amount) ? $money_char. sprintf('%.2f', ($fee_display eq 'add') ? $amount + $fee : $amount - $fee ) : '' %> <% $fee_display eq 'add' ? 'TOTAL' : 'AVAILABLE' %></FONT>
+  
+%        }
+      </TD></TR></TABLE>
+    </TD>
+  </TR>
+
+% if ( $fee ) {
+
+    <SCRIPT TYPE="text/javascript">
+
+      function amount_changed(what) {
+
+
+        var total = '';
+        if ( what.value.length ) {
+          total = parseFloat(what.value) <% $fee_op %> <% $fee %>;
+          /* total = Math.round(total*100)/100; */
+          total = '<% $money_char %>' + total.toFixed(2);
+        }
+
+        var total_cell = document.getElementById('ajax_total_cell');
+        total_cell.innerHTML = '<FONT SIZE="+1">' + total + ' <% $fee_display eq 'add' ? 'TOTAL' : 'AVAILABLE' %></FONT>';
+
+      }
+
+    </SCRIPT>
+
+% }
+
+<%init>
+
+my %opt = @_;
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $fee = '';
+my $fee_pkg = '';
+my $fee_display = '';
+my $fee_op = '';
+
+if ( $opt{'process-pkgpart'}
+     and ! $opt{'process-skip_first'} || $opt{'num_payments'}
+   )
+{
+
+  $fee_display = $opt{'process-display'} || 'add';
+  $fee_op = $fee_display eq 'add' ? '+' : '-';
+
+  $fee_pkg =
+    qsearchs('part_pkg', { pkgpart=>$opt{'process-pkgpart'} } );
+
+  #well ->unit_setup or ->calc_setup both call for a $cust_pkg
+  # (though ->unit_setup doesn't use it...)
+  $fee = $fee_pkg->option('setup_fee')
+    if $fee_pkg; #in case.. better than dying with a perl traceback
+
+}
+
+my $amount = $opt{'amount'};
+if ( $amount > 0 ) {
+  $amount += $fee
+    if $fee && $fee_display eq 'subtract';
+
+  &{ $opt{post_fee_callback} }( \$amount ) if $opt{post_fee_callback};
+
+  $amount = sprintf("%.2f", $amount);
+}
+
+</%init>
index 3600f2c..f774616 100644 (file)
@@ -77,15 +77,16 @@ any delimiter and linked from the elements in @data.
 %   } 
 %   
 % } elsif ( $cgi->param('_type') =~ /(xls)$/ ) {
-%
-%   #http_header('Content-Type' => 'application/excel' ); #eww
-%   http_header('Content-Type' => 'application/vnd.ms-excel' );
-%   #http_header('Content-Type' => 'application/msexcel' ); #alas
-%   http_header('Content-Disposition' => "attachment;filename=$filename.xls");
+%   #false laziness w/  search/elements/search-xls
+%   my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format;
+%   $filename .= $format->{extension};
+%   
+%   http_header('Content-Type' => $format->{mime_type} );
+%   http_header('Content-Disposition' => qq!attachment;filename="$filename"! );
 %
 %   my $output = '';
 %   my $XLS = new IO::Scalar \$output;
-%   my $workbook = Spreadsheet::WriteExcel->new($XLS)
+%   my $workbook = $format->{class}->new($XLS)
 %     or die "Error opening .xls file: $!";
 %
 %   my $worksheet = $workbook->add_worksheet(substr($opt{'title'},0,31));
@@ -304,9 +305,6 @@ td.cell {
 
 <% include('/elements/footer.html') %>
 % } 
-<%once>
-
-</%once>
 <%init>
 
 my(%opt) = @_;
index 348f0a6..f9a46a8 100755 (executable)
   &>
 % }
 
-% if ( ( $method eq 'adjourn' or $method eq 'suspend' ) and 
+% if ( $method eq 'adjourn' || $method eq 'suspend' ) {
+    <TR><TD COLSPAN=2>
+%   if ( $part_pkg->option('suspend_bill', 1) ) {
+      <& /elements/checkbox.html, name=>'no_suspend_bill', value=>'Y' &>
+      Disable recurring billing while suspended
+%   } else {
+      <& /elements/checkbox.html, name=>'suspend_bill', value=>'Y' &>
+      Continue recurring billing while suspended
+%   }
+    </TD></TR>
+% }
+
+% if ( ( $method eq 'adjourn' || $method eq 'suspend' ) and 
 %      $curuser->access_right('Unsuspend customer package') )  { #later?
 %   my $resume_date = $cgi->param('error') 
 %                     ? str2time($cgi->param('resume_date'))
index 57fdd64..c5f4509 100644 (file)
@@ -44,6 +44,8 @@
         <INPUT TYPE="text" NAME="quantity" SIZE=4 VALUE="<% $quantity %>">
       </TD>
     </TR>
+% } else {
+    <INPUT TYPE="hidden" NAME="quantity" VALUE="1">
 % }
 
 <TR>
index 093494a..1ae15b9 100644 (file)
@@ -9,67 +9,20 @@
 <& /elements/init_overlib.html &>
 
 <% ntable('#cccccc') %>
-  <TR>
-    <TH ALIGN="right"><% mt('Payment amount') |h %></TH>
-    <TD COLSPAN=7>
-      <TABLE><TR><TD BGCOLOR="#ffffff">
-        <% $money_char %><INPUT NAME     = "amount"
-                                ID       = "amount"
-                                TYPE     = "text"
-                                VALUE    = "<% $amount %>"
-                                SIZE     = 8
-                                STYLE    = "text-align:right;"
-%                               if ( $fee ) {
-                                  onChange   = "amount_changed(this)"
-                                  onKeyDown  = "amount_changed(this)"
-                                  onKeyUp    = "amount_changed(this)"
-                                  onKeyPress = "amount_changed(this)"
-%                               }
-                         >
-      </TD><TD BGCOLOR="#cccccc">
-%        if ( $fee ) {
-           <INPUT TYPE="hidden" NAME="fee_pkgpart" VALUE="<% $fee_pkg->pkgpart %>">
-           <INPUT TYPE="hidden" NAME="fee" VALUE="<% $fee_display eq 'add' ? $fee : '' %>">
-           <B><FONT SIZE='+1'><% $fee_op %></FONT>
-              <% $money_char . $fee %>
-           </B>
-           <% $fee_pkg->pkg |h %>
-           <B><FONT SIZE='+1'>=</FONT></B>
-      </TD><TD ID="ajax_total_cell" BGCOLOR="#dddddd" STYLE="border:1px solid blue">
-           <FONT SIZE="+1"><% length($amount) ? $money_char. sprintf('%.2f', ($fee_display eq 'add') ? $amount + $fee : $amount - $fee ) : '' %> <% $fee_display eq 'add' ? 'TOTAL' : 'AVAILABLE' %></FONT>
-  
-%        }
-      </TD></TR></TABLE>
-    </TD>
-  </TR>
-
-% if ( $fee ) {
-
-    <SCRIPT TYPE="text/javascript">
-
-      function amount_changed(what) {
-
-
-        var total = '';
-        if ( what.value.length ) {
-          total = parseFloat(what.value) <% $fee_op %> <% $fee %>;
-          /* total = Math.round(total*100)/100; */
-          total = '<% $money_char %>' + total.toFixed(2);
-        }
-
-        var total_cell = document.getElementById('ajax_total_cell');
-        total_cell.innerHTML = '<FONT SIZE="+1">' + total + ' <% $fee_display eq 'add' ? 'TOTAL' : 'AVAILABLE' %></FONT>';
-
-      }
-
-    </SCRIPT>
 
-% }
+  <& /elements/tr-amount_fee.html,
+       'amount'             => $amount,
+       'process-pkgpart'    => scalar($conf->config('manual_process-pkgpart')),
+       'process-display'    => scalar($conf->config('manual_process-display')),
+       'process-skip-first' => $conf->exists('manual_process-skip_first'),
+       'num_payments'       => scalar($cust_main->cust_pay), 
+       'post_fee_callback'  => $post_fee_callback,
+  &>
 
-<& /elements/tr-select-discount_term.html,
-             'custnum' => $custnum,
-             'amount_id' => 'amount',
-&>
+  <& /elements/tr-select-discount_term.html,
+       'custnum'   => $custnum,
+       'amount_id' => 'amount',
+  &>
 
 % if ( $payby eq 'CARD' ) {
 %
@@ -304,8 +257,6 @@ my $payinfo = '';
 
 my $conf = new FS::Conf;
 
-my $money_char = $conf->config('money_char') || '$';
-
 #false laziness w/selfservice make_payment.html shortcut for one-country
 my %states = map { $_->state => 1 }
                qsearch('cust_main_county', {
@@ -313,43 +264,23 @@ my %states = map { $_->state => 1 }
                } );
 my @states = sort { $a cmp $b } keys %states;
 
-my $fee = '';
-my $fee_pkg = '';
-my $fee_display = '';
-my $fee_op = '';
-my $num_payments = scalar($cust_main->cust_pay);
-#handle old cust_main.pm (remove...)
-$num_payments = scalar( @{ [ $cust_main->cust_pay ] } )
-  unless defined $num_payments;
-if ( $conf->config('manual_process-pkgpart')
-     and ! $conf->exists('manual_process-skip_first') || $num_payments
-   )
-{
-
-  $fee_display = $conf->config('manual_process-display') || 'add';
-  $fee_op = $fee_display eq 'add' ? '+' : '-';
-
-  $fee_pkg =
-    qsearchs('part_pkg', { pkgpart=>$conf->config('manual_process-pkgpart') } );
-
-  #well ->unit_setup or ->calc_setup both call for a $cust_pkg
-  # (though ->unit_setup doesn't use it...)
-  $fee = $fee_pkg->option('setup_fee')
-    if $fee_pkg; #in case.. better than dying with a perl traceback
-
-}
-
 my $amount = '';
 if ( $balance > 0 ) {
   $amount = $balance;
-  $amount += $fee
-    if $fee && $fee_display eq 'subtract';
+}
+
+my $post_fee_callback = sub {
+  my( $amountref ) = @_;
+
+  return unless $$amountref > 0;
+
+  my $conf = new FS::Conf;
 
   my $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage');
-  $amount += $amount * $cc_surcharge_pct/100 if $cc_surcharge_pct > 0;
+  $$amountref += $$amountref * $cc_surcharge_pct/100 if $cc_surcharge_pct > 0;
 
-  $amount = sprintf("%.2f", $amount);
-}
+  $$amountref = sprintf("%.2f", $$amountref);
+};
 
 my $payunique = "webui-payment-". time. "-$$-". rand() * 2**32;
 
index b2d7bfa..a106b84 100755 (executable)
@@ -52,10 +52,15 @@ if ($method eq 'expire' || $method eq 'adjourn' || $method eq 'resume') {
   $method = 'unsuspend' if $method eq 'resume';
 }
 
-my $resume_date;
+my $resume_date = '';
+my $options = '';
 if ( $method eq 'suspend' ) { #or 'adjourn'
   $resume_date = parse_datetime($cgi->param('resume_date'))
     if $cgi->param('resume_date');
+
+  $options = { map { $_ => scalar($cgi->param($_)) }
+                 qw( suspend_bill no_suspend_bill )
+             };
 }
 
 my $cust_pkg = qsearchs( 'cust_pkg', {'pkgnum'=>$pkgnum} );
@@ -88,6 +93,7 @@ my $error = $cust_pkg->$method( 'reason'      => $reasonnum,
                                 'last_bill'   => $last_bill,
                                 'bill'        => $bill,
                                 'svc_fatal'   => $svc_fatal,
+                                'options'     => $options,
                               );
 
 if ($error) {
index bd6bb86..932cf1a 100644 (file)
@@ -48,7 +48,8 @@ unless ( $error ) { # if ($access_user) {
   my %param = $access_user->options;
 
   #XXX autogen
-  my @paramlist = qw( locale menu_position default_customer_view mobile_menu
+  my @paramlist = qw( locale menu_position default_customer_view 
+                      spreadsheet_format mobile_menu
                       disable_html_editor disable_enter_submit_onetimecharge
                       email_address
                       snom-ip snom-username snom-password
index 8e56355..9ebf2f1 100644 (file)
@@ -75,6 +75,21 @@ Interface
       </SELECT>
     </TD>
   </TR>
+  
+  <TR>
+    <TH ALIGN="right">Spreadsheet download format: </TH>
+    <TD COLSPAN=2>
+      <SELECT NAME="spreadsheet_format">
+%       my $xls =  $curuser->option('spreadsheet_format') eq 'XLS';
+%       my $xlsx = $curuser->option('spreadsheet_format') eq 'XLSX';
+        <OPTION VALUE=""></OPTION>
+        <OPTION VALUE="XLS"<%  $xls ? 'SELECTED' : '' %>>XLS (Excel 97/2000/XP)
+        </OPTION>
+        <OPTION VALUE="XLSX"<% $xlsx ? 'SELECTED' : ''%>>XLSX (Excel 2007+)
+        </OPTION>
+      </SELECT>
+    </TD>
+  </TR>
  
   <TR>
     <TH ALIGN="right" COLSPAN=1>Disable HTML editor for customer notes: </TH>
index 55ebc0b..0987fea 100755 (executable)
@@ -34,9 +34,11 @@ $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 $search_hash{report_option} = $cgi->param('partv_report_option')
   if $cgi->param('partv_report_option');
 
-my $sql_query = FS::cust_pkg->search( { %search_hash, 'fcc_line' => 1 });
-$sql_query->{select} = 'DISTINCT substr(zip,1,5) as zip';
-$sql_query->{order_by} = 'ORDER BY substr(zip,1,5)';
+my $sql_query = FS::cust_pkg->search( { %search_hash,
+                                        'fcc_line'    => 1,
+                                        'select_zip5' => 1,
+                                      }
+                                    );
 my $count_query = delete($sql_query->{'count_query'});
 $count_query =~ s/COUNT\(\*\)/count(DISTINCT substr(zip,1,5))/;
 $count_query =~ s/ORDER BY [.\w]+//;
diff --git a/httemplate/search/cust_bill_pkg_referral.html b/httemplate/search/cust_bill_pkg_referral.html
new file mode 100644 (file)
index 0000000..3cb434c
--- /dev/null
@@ -0,0 +1,294 @@
+<& elements/search.html,
+  'title'       => emt('Sales with advertising source'),
+  'name'        => emt('line items'),
+  'query'       => $query,
+  'count_query' => $count_query,
+  'count_addl'  => [ 
+                     ($setup ? $money_char. '%.2f setup' : ()),
+                     ($recur ? $money_char. '%.2f recurring' : ()),
+                     ($usage ? $money_char. '%.2f usage' : ()),
+                   ],
+  'header'      => [
+    emt('Description'),
+    ($setup ? emt('Setup') : ()),
+    ($recur ? emt('Recurring') : ()),
+    ($usage ? emt('Usage') : ()),
+    emt('Invoice'),
+    emt('Invoice date'),
+    emt('Paid'),
+    emt('Payment date'),
+    emt('Pkg. status'),
+    emt('Pkg. class'),
+    '', #report class
+    emt('Cust#'),
+    emt('Customer'),
+    emt('Ad source'),
+    emt('Agent'),
+  ],
+  'fields'      => [
+    'pkg',
+    ($setup ? money_sub('setup') : ()),
+    ($recur ? money_sub('recur_no_usage') : ()),
+    ($usage ? money_sub('recur_usage') : ()),
+    'invnum',
+    date_sub('_date'),
+    money_sub('paid'),
+    date_sub('last_pay'),
+    sub {
+      my $cust_pkg = shift->cust_pkg;
+      $cust_pkg ? ucfirst($cust_pkg->status) : '';
+    },
+    'classname',
+    sub { # report_option
+      my $cust_bill_pkg = shift;
+      my $pkgpart = $cust_bill_pkg->pkgpart_override
+                 || $cust_bill_pkg->cust_pkg->pkgpart;
+      if ( !exists($report_classes{$pkgpart}) ) {
+        my $part_pkg = FS::part_pkg->by_key($pkgpart);
+        my %opts = $part_pkg->options;
+        $report_classes{$pkgpart} = [
+          map { /^report_option_(\d+)/ ? 
+                $report_option_name{$1} :
+                () }
+          keys %opts
+        ];
+      }
+      join( '<BR>', @{ $report_classes{$pkgpart} });
+    },
+    'custnum',
+    'name',
+    'referral', # from query
+    'agent',
+  ],
+  'sort_fields' => [
+    '',
+    ($setup ? 'setup' : ()),
+    ($recur ? 'recur_no_usage' : ()),
+    ($usage ? 'recur_usage' : ()),
+    'invnum',
+    '_date',
+    'paid',
+    'last_pay',
+    '', #package status
+    'classname',
+    '', #report_option
+    'custnum',
+    '',
+    'referral',
+    'agent',
+  ],
+  'links'       => [
+    '', #package/item desc
+    ('') x $x, #setup/recur/usage
+    $ilink, #invnum
+    $ilink, #invoice date
+    '', #paid amt
+    '', #payment date
+    '', #pkg status
+    '', #classnum
+    '', #report class
+    $clink, #custnum
+    $clink, #customer name
+    '', #referral
+    '', #agent
+  ],
+  #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(),
+  'align' => 'l' . ('r' x $x) . 'rcrccccrlll',
+  'color' => [ ('') x (5 + $x),
+                sub {
+                  my $cust_pkg = shift->cust_pkg;
+                  $cust_pkg ? ucfirst($cust_pkg->statuscolor) : '';
+                },
+               ('') x 6,
+             ],
+  'style' => [
+               ('') x (5 + $x),
+               'b',
+               ('') x 6
+             ],
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $conf = new FS::Conf;
+
+my $setup = $cgi->param('setup') ? 1 : 0;
+my $recur = $cgi->param('recur') ? 1 : 0;
+my $usage = $cgi->param('usage') ? 1 : 0;
+
+my $x = $setup + $recur + $usage;
+
+my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
+my ($join_cust, $join_pkg ) = ('', '');
+
+#here is the agent virtualization
+my $agentnums_sql =
+  $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
+
+my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
+
+my @where = ( $agentnums_sql,
+              'cust_bill_pkg.pkgnum != 0', # exclude taxes
+              "cust_bill._date >= $beginning",
+              "cust_bill._date <= $ending",
+            );
+
+my @status_where;
+foreach my $status ($cgi->param('status')) {
+  if ( $status =~ /^([- a-z]+)$/ ) { #"one-time charge"
+    push @status_where, "'$status'";
+  }
+}
+if ( @status_where ) {
+  push @where, '('. FS::cust_pkg->status_sql.
+    ') IN (' . join(',', @status_where) .')';
+}
+
+if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  push @where, "cust_main.agentnum = $1";
+}
+
+#classnum
+# not specified: all classes
+# 0: empty class
+# N: classnum
+my $use_override = 1; #$cgi->param('use_override');
+if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
+  my $comparison = '';
+  if ( $1 == 0 ) {
+    $comparison = "IS NULL";
+  } else {
+    $comparison = "= $1";
+  }
+
+  if ( $use_override ) {
+    push @where, "(
+      part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
+      override.classnum $comparison AND pkgpart_override IS NOT NULL
+    )";
+  } else {
+    push @where, "part_pkg.classnum $comparison";
+  }
+}
+
+# report option
+my @report_option = grep /^\d+$/, ( $cgi->param('report_option') );
+if ( @report_option ) {
+  @report_option = map { "'report_option_$_'" } @report_option;
+  push @where, "EXISTS( 
+    SELECT 1 FROM part_pkg_option WHERE optionname IN (".
+    join(',', @report_option).") AND (
+      part_pkg_option.pkgpart = cust_pkg.pkgpart AND pkgpart_override IS NULL
+      OR part_pkg_option.pkgpart = pkgpart_override
+    )
+  )";
+}
+
+my $setup_sql =
+  FS::cust_bill_pkg->charged_sql('', '', setuprecur => 'setup');
+my $recur_sql =
+  FS::cust_bill_pkg->charged_sql('', '', setuprecur => 'recur', no_usage => 1);
+my $usage_sql = FS::cust_bill_pkg->usage_sql;
+
+# exclude zero-amount items
+my @orwhere;
+push @orwhere, "(cust_bill_pkg.setup > 0)" if $setup;
+push @orwhere, "($recur_sql > 0)"          if $recur;
+push @orwhere, "($usage_sql > 0)"          if $usage;
+push @where, '('.join(' OR ', @orwhere).')' if @orwhere;
+
+$join_cust =  '        JOIN cust_bill     USING ( invnum )
+                  LEFT JOIN cust_main     USING ( custnum )
+                  LEFT JOIN part_referral USING ( refnum )
+                  LEFT JOIN agent ON cust_main.agentnum = agent.agentnum
+              ';
+
+$join_pkg .=  ' LEFT JOIN cust_pkg USING ( pkgnum )
+                LEFT JOIN part_pkg USING ( pkgpart )
+                LEFT JOIN part_pkg AS override
+                  ON pkgpart_override = override.pkgpart 
+                LEFT JOIN pkg_class ON '; #...
+
+if ( $use_override ) {
+  # join to whichever pkgpart is appropriate
+  $join_pkg .= '
+      ( pkgpart_override IS NULL     AND part_pkg.classnum = pkg_class.classnum )
+   OR ( pkgpart_override IS NOT NULL AND override.classnum = pkg_class.classnum )';
+} else {
+  $join_pkg .= 'part_pkg.classnum = pkg_class.classnum';
+}
+
+my $where = ' WHERE '. join(' AND ', @where);
+
+# setup and recurring only
+my $count_query = "SELECT 
+  COUNT(billpkgnum)".
+  ($setup ? ", SUM($setup_sql)" : '').
+  ($recur ? ", SUM($recur_sql)" : '').
+  ($usage ? ", SUM($usage_sql)" : '').
+  " FROM cust_bill_pkg
+  $join_cust
+  $join_pkg
+  $where
+  ";
+
+my $paid_sql = FS::cust_bill_pkg->paid_sql('', '');
+my $last_pay_sql = "SELECT MAX(_date)
+  FROM cust_bill_pay JOIN cust_bill_pay_pkg USING (billpaynum)
+  WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum";
+
+push @select, 'part_pkg.pkg',
+              'part_pkg.freq',
+              'cust_main.custnum',
+              'cust_main.first',
+              'cust_main.last',
+              'cust_main.company',
+              'part_referral.referral',
+              "($paid_sql) AS paid",
+              "($last_pay_sql) AS last_pay",
+              "($recur_sql) AS recur_no_usage",
+              "($usage_sql) AS recur_usage",
+              'pkg_class.classname',
+              'agent.agent',
+              ;
+
+my $query = {
+  'table'     => 'cust_bill_pkg',
+  'addl_from' => "$join_cust $join_pkg",
+  'hashref'   => {},
+  'select'    => join(",\n", @select ),
+  'extra_sql' => $where,
+  'order_by'  => 'ORDER BY cust_bill._date, billpkgnum',
+};
+
+my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
+my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my %report_classes; #cache
+my %report_option_name = 
+  map { $_->num => $_->name } qsearch('part_pkg_report_option', {});
+
+# should this be in Mason.pm or something?
+sub money_sub {
+  $conf ||= new FS::Conf;
+  $money_char ||= $conf->config('money_char') || '$';
+  my $field = shift;
+  sub {
+    $money_char . sprintf('%.2f', $_[0]->get($field));
+  };
+}
+
+sub date_sub {
+  my $field = shift;
+  sub {
+    my $value = $_[0]->get($field);
+    $value ? time2str('%b %d %Y', $value) : '';
+  };
+}
+
+</%init>
index e87b214..c317dc3 100644 (file)
@@ -5,7 +5,7 @@
                  'count_query' => $count_sql,
                  'header'      => [ 'Zip code', 'Customers', ],
                  #'fields'      => [ 'zip', 'num_cust', ],
-                 'links'       => [ '', sub { 'somewhere'; }  ],
+                 #'links'       => [ '', sub { 'somewhere'; }  ],
              )
 %>
 <%init>
index 005b761..739e65b 100644 (file)
@@ -103,7 +103,7 @@ Batch is <% $statustext{$status} %><BR>
 % }
 </%def>
 <%shared>
-my $show_gateways = FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment'");
+my $show_gateways = FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment' AND disabled IS NULL");
 </%shared>
 <%init>
 my %opt = @_;
index dc3cb2a..c604111 100755 (executable)
@@ -357,6 +357,15 @@ if ( $cgi->param('magic') ) {
 
     $orderby = "LOWER(company || ' ' || last || ' ' || first )";
 
+  } elsif ( $cgi->param('magic') eq 'batchnum' ) {
+
+    $cgi->param('batchnum') =~ /^(\d+)$/
+      or die "illegal batchnum: ".$cgi->param('batchnum');
+
+    push @search, "batchnum = $1";
+
+    $orderby = "LOWER(company || ' ' || last || ' ' || first )";
+
   } else {
     die "unknown search magic: ". $cgi->param('magic');
   }
index 0b5636c..09dbe46 100644 (file)
@@ -7,14 +7,20 @@ my $header = $args{'header'};
 my $rows   = $args{'rows'};
 my %opt    = %{ $args{'opt'} };    
 
+my $override = scalar(@$rows) >= 65536 ? 'XLSX' : '';
+
+my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override);
+
+my $filename = $opt{'name'} || PL($opt{'name_singular'});
+$filename .= $format->{extension};
+
 #http_header('Content-Type' => 'application/excel' ); #eww
 #http_header('Content-Type' => 'application/msexcel' ); #alas
 #http_header('Content-Type' => 'application/x-msexcel' ); #?
 
 #http://support.microsoft.com/kb/199841
-http_header('Content-Type' => 'application/vnd.ms-excel' );
-http_header('Content-Disposition' => 
-  'attachment;filename="'.($opt{'name'} || PL($opt{'name_singular'}) ).'.xls"');
+http_header('Content-Type' => $format->{mime_type} );
+http_header('Content-Disposition' => qq!attachment;filename="$filename"! );
  
 #http://support.microsoft.com/kb/812935
 #http://support.microsoft.com/kb/323308
@@ -22,8 +28,8 @@ $HTML::Mason::Commands::r->headers_out->{'Cache-control'} = 'max-age=0';
 
 my $data = '';
 my $XLS = new IO::Scalar \$data;
-my $workbook = Spreadsheet::WriteExcel->new($XLS)
-  or die "Error opening .xls file: $!";
+my $workbook = $format->{class}->new($XLS)
+  or die "Error opening Excel file: $!";
 
 my $worksheet = $workbook->add_worksheet(substr($opt{'title'},0,31));
 
@@ -42,14 +48,18 @@ my $default_format = $workbook->add_format(locked => 0);
 my %money_format;
 my $money_char = FS::Conf->new->config('money_char') || '$';
 
+my %date_format;
+xl_parse_date_init();
+
 my $writer = sub {
   # Wrapper for $worksheet->write.
   # Do any massaging of the value/format here.
   my ($r, $c, $value, $format) = @_;
-  if ( $value =~ /^\Q$money_char\E(\d+\.?\d*)$/ ) {
+  if ( $value =~ /^\Q$money_char\E(-?\d+\.?\d*)$/ ) {
     # Currency: strip the symbol, clone the requested format,
     # and format it for currency
     $value = $1;
+#    warn "formatting $value as money\n";
     if ( !exists($money_format{$format}) ) {
       $money_format{$format} = $workbook->add_format();
       $money_format{$format}->copy($format);
@@ -57,6 +67,22 @@ my $writer = sub {
     }
     $format = $money_format{$format};
   }
+  elsif ( $value =~ /^([A-Z][a-z]{2}) (\d{2}) (\d{4})$/ ) {
+    # Date: convert the value to an Excel date number and set 
+    # the format
+    $value = xl_parse_date($value);
+#    warn "formatting $value as date\n";
+    if ( !exists($date_format{$format}) ) {
+      $date_format{$format} = $workbook->add_format();
+      $date_format{$format}->copy($format);
+      $date_format{$format}->set_num_format('mmm dd yyyy');
+    }
+    $format = $date_format{$format};
+  }
+  else {
+    # String: replace line breaks with newlines
+    $value =~ s/<BR>/\n/gi;
+  }
   $worksheet->write($r, $c, $value, $format);
 };
 
index 05415f3..aeaa012 100755 (executable)
                                      'Type',
                                      'First Download',
                                      'Last Upload',
-                                     'Items',
-                                      'Unresolved',
-                                     'Amount',
+                                      '', # requests
+                                      '', # req amt
+                                      '', # payments
+                                      '', # pay amt
                                      'Status',
                                     ],
-                'align'         => 'rcllrrc',
+                'align'         => 'rcllrrrrc',
                 'fields'        => [ 'batchnum',
                                      sub { 
                                        FS::payby->shortname(shift->payby);
                                        }
                                      },
                                      sub {
-                                        FS::cust_pay_batch->count(
-                                          'batchnum = '.$_[0]->batchnum
-                                        )
+                                        my $c = FS::cust_pay_batch->count('batchnum = '.$_[0]->batchnum);
+                                        $c ? "$c requested" : ''
                                       },
                                       sub {
-                                        FS::cust_pay_batch->count(
-                                          'status is null and batchnum = '.
-                                            $_[0]->batchnum
-                                        )
-                                      },
-                                     sub {
                                         my $st = "SELECT SUM(amount) from cust_pay_batch WHERE batchnum=" . shift->batchnum;
                                         my $sth = dbh->prepare($st)
-                                         or die dbh->errstr. "doing $st";
+                                          or die dbh->errstr. "doing $st";
                                         $sth->execute
-                                         or die "Error executing \"$st\": ". $sth->errstr;
-                                        $sth->fetchrow_arrayref->[0];
-                                     },
+                                          or die "Error executing \"$st\": ". $sth->errstr;
+                                        my $total = $sth->fetchrow_arrayref->[0];
+                                        $total ? $money_char.sprintf('%.2f',$total) : '';
+                                      },
+                                      sub {
+                                        my $c = FS::cust_pay->count('batchnum = '.$_[0]->batchnum);
+                                        $c ? "$c paid" : ''
+                                      },
+                                      sub {
+                                        my $st = "SELECT SUM(paid) from cust_pay WHERE batchnum=" . shift->batchnum;
+                                        my $sth = dbh->prepare($st)
+                                          or die dbh->errstr. "doing $st";
+                                        $sth->execute
+                                          or die "Error executing \"$st\": ". $sth->errstr;
+                                        my $total = $sth->fetchrow_arrayref->[0];
+                                        $total ? $money_char.sprintf('%.2f',$total) : '';
+                                      },
                                       sub {
                                        $statusmap{shift->status};
                                      },
                                    ],
                 'links'         => [
-                                     $link,
+                                     '',
                                      '',
-                                     sub { shift->status eq 'O' ? $link : '' },
-                                     sub { shift->status eq 'I' ? $link : '' },
+                                      sub { shift->status eq 'O' ? $cpb_link : '' },
+                                      sub { shift->status eq 'I' ? $cpb_link : '' },
+                                      $cpb_link,
+                                      $cpb_link,
+                                      $pay_link,
+                                      $pay_link,
                                    ],
                 'size'         => [
                                      '',
                                      sub { shift->status eq 'I' ? "b" : '' },
                                    ],
                  'html_init'     => $html_init,
+                 'html_foot'     => include('.upload_incoming'),
       )
-
 %>
+<%def .upload_incoming>
+% if ( FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment' AND disabled IS NULL") > 0 ) { 
+<& /elements/form-file_upload.html,
+    name      => 'FileUpload',
+    action    => $p.'misc/upload-batch.cgi',
+    num_files => 1,
+    fields    => [ 'gatewaynum' ],
+    message   => 'Incoming batch uploaded.',
+&>
+<BR>
+<BR>
+Upload incoming batch from gateway 
+<& /elements/select-table.html,
+    table       => 'payment_gateway',
+    field       => 'gatewaynum',
+    name_col    => 'label',
+    value_col   => 'gatewaynum',
+    order_by    => 'ORDER BY gatewaynum',
+    empty_label => ' ',
+    hashref     =>
+      { 'gateway_namespace' => 'Business::BatchPayment',
+        'disabled' => '' },
+&>
+<BR>
+<& '/elements/file-upload.html',
+    field     => 'file',
+    label     => 'Filename',
+    no_table  => 1,
+&>
+<INPUT TYPE="submit" VALUE="Upload">
+</FORM>
+% }
+</%def>
 <%init>
 
 die "access denied"
@@ -134,11 +179,14 @@ push @where,
 
 my $extra_sql = scalar(@where) ? 'WHERE ' . join(' AND ', @where) : ''; 
 
-my $link = [ "${p}search/cust_pay_batch.cgi?dcln=1;batchnum=", 'batchnum' ];
+my $cpb_link = [ "${p}search/cust_pay_batch.cgi?dcln=1;batchnum=", 'batchnum' ];
+my $pay_link = [ "${p}search/cust_pay.html?magic=batchnum;batchnum=", 'batchnum' ];
 
 my $resolved = $cgi->param('resolved') || 0;
 $cgi->param('resolved' => !$resolved);
 my $html_init = '<A HREF="' . $cgi->self_url . '"><I>'.
     ($resolved ? 'Hide' : 'Show') . ' resolved batches</I></A><BR>';
 
+my $money_char = FS::Conf->new->config('money_char') || '$';
+
 </%init>
diff --git a/httemplate/search/report_cust_bill_pkg_referral.html b/httemplate/search/report_cust_bill_pkg_referral.html
new file mode 100644 (file)
index 0000000..ff2caa1
--- /dev/null
@@ -0,0 +1,61 @@
+<% include('/elements/header.html', 'Sales Report with Advertising Source' ) %>
+
+<FORM ACTION="cust_bill_pkg_referral.html" METHOD="GET">
+
+<TABLE>
+
+<& /elements/tr-input-beginning_ending.html &>
+
+<& /elements/tr-select-agent.html,
+  'label'         => 'For agent: ',
+  'disable_empty' => 0,
+  'empty_label'   => 'all',
+&>
+
+<& /elements/tr-select-cust_pkg-status.html,
+  'label'         => 'Package status',
+  'multiple'      => 1,
+  'disable_empty' => 1,
+&>
+
+<& /elements/tr-select-pkg_class.html,
+  'pre_options' => [ '' => 'all', '0' => '(empty class)' ],
+  'disable_empty' => 1,
+&>
+
+<& /elements/tr-select-table.html,
+  'label'         => 'Report classes',
+  'table'         => 'part_pkg_report_option',
+  'name_col'      => 'name',
+  'hashref'       => { disabled => '' },
+  'element_name'  => 'report_option',
+  'multiple'      => 1,
+&>
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="setup" VALUE="1" CHECKED></TD>
+  <TD>Show setup/one-time fees</TD>
+</TR>
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="recur" VALUE="1" CHECKED></TD>
+  <TD>Show recurring fees</TD>
+</TR>
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="usage" VALUE="1" CHECKED></TD>
+  <TD>Show usage charges</TD>
+</TR>
+
+</TABLE>
+
+<BR><INPUT TYPE="submit" VALUE="Display">
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+</%init>
index 1c278df..f19f85a 100755 (executable)
@@ -1,9 +1,25 @@
 <% $data %>
 <%init>
+my $htmldoc = include('report_tax.cgi');
+
+my ($title) = ($htmldoc =~ /<title>\s*(.*)\s*<\/title>/i);
+
+# do this first so we can override the format if it's too many rows
+# attribs option: how to locate the table?  It's the only one with class="grid".
+my $te = HTML::TableExtract->new(attribs => {class => 'grid'});
+$te->parse($htmldoc);
+my $table = $te->first_table_found;
+
+my $override = ($table->row_count >= 65536 ? 'XLSX' : '');
+my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override);
+my $filename = 'report_tax'.$format->{extension};
+
+http_header('Content-Type' => $format->{mime_type});
+http_header('Content-Disposition' => qq!attachment;filename="$filename"! );
 
 my $data = '';
 my $XLS = new IO::Scalar \$data;
-my $workbook = Spreadsheet::WriteExcel->new($XLS)
+my $workbook = $format->{class}->new($XLS)
   or die "Error opening .xls file: $!";
 
 # hardcoded formats, this could be handled better
@@ -66,15 +82,6 @@ foreach (keys(%format)) {
 }
 my $ws = $workbook->add_worksheet('taxreport');
 
-my $htmldoc = include('report_tax.cgi');
-
-my ($title) = ($htmldoc =~ /<title>\s*(.*)\s*<\/title>/i);
-
-# attribs option: how to locate the table?  It's the only one with class="grid".
-my $te = HTML::TableExtract->new(attribs => {class => 'grid'});
-$te->parse($htmldoc);
-my $table = $te->first_table_found;
-
 my @sheet;
 $sheet[0][0] = {
   text    => $title,
@@ -148,6 +155,4 @@ for my $x (0..scalar(@widths)-1) {
 
 $workbook->close;
 
-http_header('Content-Type' => 'application/vnd.ms-excel');
-http_header('Content-Disposition' => 'attachment;filename="report_tax.xls"');
 </%init>
index 4aec90e..e901774 100644 (file)
 
     <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
     <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
-%   if ( $part_pkg->option('suspend_bill', 1) ) {
+%   if ( $cust_pkg->option('suspend_bill', 1)
+%        || ( $part_pkg->option('suspend_bill', 1)
+%               && ! $cust_pkg->option('no_suspend_bill',1)
+%           )
+%      )
+%   {
       <% pkg_status_row_if( $cust_pkg, emt('Next bill'), 'bill', %opt, curuser=>$curuser ) %>
 %   }
     <% pkg_status_row_if( $cust_pkg, emt('Will resume'), 'resume', %opt, curuser=>$curuser ) %>
index 7a0e198..7c2c207 100755 (executable)
@@ -23,13 +23,17 @@ my $cust_main = qsearchs({
   'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
 });
 die "Customer #$custnum not found!" unless $cust_main;
+my $cust_bill = ($cust_main->cust_bill)[-1]
+  or die "Customer #$custnum has no invoices!";
 
 my $cust_statement = FS::cust_statement->new({
   'custnum'       => $custnum,
-  'statementnum'  => 'ALL', #magic
+#  'statementnum'  => 'ALL', #magic
+  'invnum'        => $cust_bill->invnum,
   '_date'         => time,
 });
 
+
 my $pdf = $cust_statement->print_pdf( '', $templatename );
 
 http_header('Content-Type' => 'application/pdf' );
index 4cf4184..dd73d90 100644 (file)
@@ -576,10 +576,10 @@ sub RescueOutlook {
         my $text_part;
         if ( $mime->head->get('Content-Type') =~ m{multipart/mixed} ) {
             my $first = $mime->parts(0);
-            if ( $first->head->get('Content-Type') =~ m{multipart/alternative} )
+            if ( $first && $first->head->get('Content-Type') =~ m{multipart/alternative} )
             {
                 my $inner_first = $first->parts(0);
-                if ( $inner_first->head->get('Content-Type') =~ m{text/plain} )
+                if ( $inner_first && $inner_first->head->get('Content-Type') =~ m{text/plain} )
                 {
                     $text_part = $inner_first;
                 }
@@ -587,7 +587,7 @@ sub RescueOutlook {
         }
         elsif ( $mime->head->get('Content-Type') =~ m{multipart/alternative} ) {
             my $first = $mime->parts(0);
-            if ( $first->head->get('Content-Type') =~ m{text/plain} ) {
+            if ( $first && $first->head->get('Content-Type') =~ m{text/plain} ) {
                 $text_part = $first;
             }
         }
index 6ba6c5b..00f88b6 100755 (executable)
@@ -2447,13 +2447,13 @@ sub _Links {
     # at least to myself
     $links->Limit(
         FIELD           => $field, #$limit_on,
-        OPERATOR        => 'LIKE',
+        OPERATOR        => 'MATCHES',
         VALUE           => 'fsck.com-rt://%/ticket/'. $self->id,
         ENTRYAGGREGATOR => 'OR',
     );
     $links->Limit(
         FIELD           => $field, #$limit_on,
-        OPERATOR        => 'LIKE',
+        OPERATOR        => 'MATCHES',
         VALUE           => 'fsck.com-rt://%/ticket/'. $_,
         ENTRYAGGREGATOR => 'OR',
     ) foreach $self->Merged;