summaryrefslogtreecommitdiff
path: root/FS/FS
diff options
context:
space:
mode:
authorIvan Kohler <ivan@freeside.biz>2012-07-26 14:05:08 -0700
committerIvan Kohler <ivan@freeside.biz>2012-07-26 14:05:08 -0700
commit8c450aab9bae89373c2c1b35c85597bb52299de3 (patch)
tree62e7400162703ad0990f80a2d71b90fc1167e759 /FS/FS
parent2528cc7b182781a82844d8bbb1b555560487abc7 (diff)
parentbd647f16de5352722baed016b45baa4e7c695278 (diff)
Merge branch 'master' of git.freeside.biz:/home/git/freeside
Diffstat (limited to 'FS/FS')
-rw-r--r--FS/FS/Conf.pm79
-rw-r--r--FS/FS/Mason.pm3
-rw-r--r--FS/FS/Schema.pm4
-rw-r--r--FS/FS/Template_Mixin.pm60
-rw-r--r--FS/FS/access_user.pm36
-rwxr-xr-xFS/FS/addr_block.pm32
-rw-r--r--FS/FS/cust_bill.pm13
-rw-r--r--FS/FS/cust_main.pm27
-rw-r--r--FS/FS/cust_main/Billing.pm5
-rw-r--r--FS/FS/cust_main/Packages.pm6
-rw-r--r--FS/FS/cust_pay.pm21
-rw-r--r--FS/FS/cust_pay_batch.pm1
-rw-r--r--FS/FS/cust_pkg.pm83
-rw-r--r--FS/FS/cust_statement.pm50
-rw-r--r--FS/FS/domain_record.pm3
-rw-r--r--FS/FS/msg_template.pm16
-rw-r--r--FS/FS/part_export/ez_prepaid.pm183
-rw-r--r--FS/FS/part_pkg/prepaid.pm2
-rw-r--r--FS/FS/pay_batch.pm341
-rw-r--r--FS/FS/payment_gateway.pm2
-rw-r--r--FS/FS/quotation.pm6
21 files changed, 783 insertions, 190 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 043594536..37bbf6e17 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -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" },
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index e26a4b747..51edd97cc 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -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;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 3fc26b071..ff40cd673 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -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' => [],
diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm
index d1bcec5d5..61cfccba8 100644
--- a/FS/FS/Template_Mixin.pm
+++ b/FS/FS/Template_Mixin.pm
@@ -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
diff --git a/FS/FS/access_user.pm b/FS/FS/access_user.pm
index 5d5cc126c..509cc0950 100644
--- a/FS/FS/access_user.pm
+++ b/FS/FS/access_user.pm
@@ -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
diff --git a/FS/FS/addr_block.pm b/FS/FS/addr_block.pm
index e00f587c6..686bdbd18 100755
--- a/FS/FS/addr_block.pm
+++ b/FS/FS/addr_block.pm
@@ -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
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 83748be1b..c3d48a61c 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -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.
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 78791dd57..96029415a 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -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
} );
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index e7b953060..bab94c31d 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -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'
diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm
index 957043a84..11c13e5dd 100644
--- a/FS/FS/cust_main/Packages.pm
+++ b/FS/FS/cust_main/Packages.pm
@@ -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;
}
diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm
index 2a2b9d025..c11738665 100644
--- a/FS/FS/cust_pay.pm
+++ b/FS/FS/cust_pay.pm
@@ -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
diff --git a/FS/FS/cust_pay_batch.pm b/FS/FS/cust_pay_batch.pm
index 5f21ff4b1..9f2e9ddfc 100644
--- a/FS/FS/cust_pay_batch.pm
+++ b/FS/FS/cust_pay_batch.pm
@@ -322,6 +322,7 @@ sub approve {
'paid' => $new->paid,
'_date' => $new->_date,
'usernum' => $new->usernum,
+ 'batchnum' => $new->batchnum,
} );
$error = $cust_pay->insert;
if ( $error ) {
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 22559e95f..aed99e51d 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -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,
diff --git a/FS/FS/cust_statement.pm b/FS/FS/cust_statement.pm
index 45fae1ccf..9954b7b90 100644
--- a/FS/FS/cust_statement.pm
+++ b/FS/FS/cust_statement.pm
@@ -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
diff --git a/FS/FS/domain_record.pm b/FS/FS/domain_record.pm
index 8d767d510..cd881ae08 100644
--- a/FS/FS/domain_record.pm
+++ b/FS/FS/domain_record.pm
@@ -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;
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index ffb4f52fb..cac7fe572 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -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
index 000000000..d171eb135
--- /dev/null
+++ b/FS/FS/part_export/ez_prepaid.pm
@@ -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;
diff --git a/FS/FS/part_pkg/prepaid.pm b/FS/FS/part_pkg/prepaid.pm
index 407343bc8..50f908c6d 100644
--- a/FS/FS/part_pkg/prepaid.pm
+++ b/FS/FS/part_pkg/prepaid.pm
@@ -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,
},
diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm
index 4f223e113..813d096b4 100644
--- a/FS/FS/pay_batch.pm
+++ b/FS/FS/pay_batch.pm
@@ -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 a 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
diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm
index fac738499..4a7585e24 100644
--- a/FS/FS/payment_gateway.pm
+++ b/FS/FS/payment_gateway.pm
@@ -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
}
diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm
index 0cfb11e2f..ccaa1c34b 100644
--- a/FS/FS/quotation.pm
+++ b/FS/FS/quotation.pm
@@ -150,6 +150,12 @@ sub cust_bill_pkg {
=back
+=item enable_previous
+
+=cut
+
+sub enable_previous { 0 }
+
=head1 BUGS
=head1 SEE ALSO