summaryrefslogtreecommitdiff
path: root/FS
diff options
context:
space:
mode:
Diffstat (limited to 'FS')
-rw-r--r--FS/FS/Conf.pm9
-rw-r--r--FS/FS/Cron/cleanup.pm16
-rwxr-xr-xFS/FS/Cron/tax_rate_update.pm2
-rw-r--r--FS/FS/Daemon.pm12
-rw-r--r--FS/FS/TaxEngine/billsoft.pm635
-rw-r--r--FS/FS/TicketSystem.pm19
-rw-r--r--FS/FS/Upgrade.pm12
-rw-r--r--FS/FS/agent.pm83
-rw-r--r--FS/FS/agent_payment_gateway.pm1
-rw-r--r--FS/FS/cust_main.pm5
-rw-r--r--FS/FS/cust_main/Billing_Realtime.pm432
-rw-r--r--FS/FS/log_context.pm2
-rw-r--r--FS/FS/payinfo_Mixin.pm3
-rw-r--r--FS/FS/payinfo_transaction_Mixin.pm2
-rw-r--r--FS/FS/payment_gateway.pm113
-rw-r--r--FS/FS/tax_status.pm6
-rwxr-xr-xFS/bin/freeside-daily2
-rwxr-xr-xFS/t/suite/13-tokenization.t179
18 files changed, 1031 insertions, 502 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index d5384e1af..b750ba5f7 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2449,11 +2449,18 @@ and customer address. Include units.',
{
'key' => 'billsoft-company_code',
'section' => 'taxation',
- 'description' => 'Billsoft tax service company code (3 letters)',
+ 'description' => 'Billsoft (AvaTax for Communications) tax service company code (3 letters)',
'type' => 'text',
},
{
+ 'key' => 'billsoft-taxconfig',
+ 'section' => 'taxation',
+ 'description' => 'Billsoft tax configuration flags. Four lines: Facilities, Franchise, Regulated, Business Class. See the Avalara documentation for instructions on setting these flags.',
+ 'type' => 'textarea',
+ },
+
+ {
'key' => 'avalara-taxconfig',
'section' => 'taxation',
'description' => 'Avalara tax service configuration. Four lines: company code, account number, license key, test mode (1 to enable).',
diff --git a/FS/FS/Cron/cleanup.pm b/FS/FS/Cron/cleanup.pm
index 6ec401398..9d0c06740 100644
--- a/FS/FS/Cron/cleanup.pm
+++ b/FS/FS/Cron/cleanup.pm
@@ -8,12 +8,26 @@ use FS::Record qw( qsearch );
# start janitor jobs
sub cleanup {
-# fix locations that are missing coordinates
+ my %opt = @_;
+
+ # fix locations that are missing coordinates
my $job = FS::queue->new({
'job' => 'FS::cust_location::process_set_coord',
'status' => 'new'
});
$job->insert('_JOB');
+
+ # check card number tokenization
+ $job = FS::queue->new({
+ 'job' => 'FS::cust_main::Billing_Realtime::token_check',
+ 'status' => 'new'
+ });
+ $job->insert(
+ %opt,
+ 'queue' => 1,
+ 'daily' => 1,
+ );
+
}
sub cleanup_before_backup {
diff --git a/FS/FS/Cron/tax_rate_update.pm b/FS/FS/Cron/tax_rate_update.pm
index b6ac63c2e..fec696fbb 100755
--- a/FS/FS/Cron/tax_rate_update.pm
+++ b/FS/FS/Cron/tax_rate_update.pm
@@ -31,7 +31,7 @@ sub tax_rate_update {
my %opt = @_;
my $oldAutoCommit = $FS::UID::AutoCommit;
- $FS::UID::AutoCommit = 0;
+ local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
my $conf = FS::Conf->new;
diff --git a/FS/FS/Daemon.pm b/FS/FS/Daemon.pm
index 4ecd80e98..a3c16d888 100644
--- a/FS/FS/Daemon.pm
+++ b/FS/FS/Daemon.pm
@@ -64,12 +64,6 @@ sub daemonize1 {
$SIG{TERM} = sub { warn "SIGTERM received; shutting down\n"; $sigterm++; };
}
- # set the logfile sensibly
- if (!$logfile) {
- my $logname = $me;
- $logname =~ s/^freeside-//;
- logfile("%%%FREESIDE_LOG%%%/$logname-log.$FS::UID::datasrc");
- }
}
sub drop_root {
@@ -122,6 +116,12 @@ sub _die {
sub _logmsg {
chomp( my $msg = shift );
+ # set the logfile sensibly
+ if (!$logfile) {
+ my $logname = $me;
+ $logname =~ s/^freeside-//;
+ logfile("%%%FREESIDE_LOG%%%/$logname-log.$FS::UID::datasrc");
+ }
my $log = new IO::File ">>$logfile";
flock($log, LOCK_EX);
seek($log, 0, 2);
diff --git a/FS/FS/TaxEngine/billsoft.pm b/FS/FS/TaxEngine/billsoft.pm
index 6bda8db37..69717a22d 100644
--- a/FS/FS/TaxEngine/billsoft.pm
+++ b/FS/FS/TaxEngine/billsoft.pm
@@ -11,9 +11,51 @@ use FS::upload_target;
use Date::Format qw( time2str );
use File::chdir;
use File::Copy qw(move);
-use Parse::FixedLength;
-
-$DEBUG = 1;
+use Text::CSV_XS;
+use Locale::Country qw(country_code2code);
+
+# "use constant" this, for performance?
+our @input_cols = qw(
+ RequestType
+ BillToCountryISO
+ BillToZipCode
+ BillToZipP4
+ BillToPCode
+ BillToNpaNxx
+ OriginationCountryISO
+ OriginationZipCode
+ OriginationZipP4
+ OriginationNpaNxx
+ TerminationCountryISO
+ TerminationZipCode
+ TerminationZipP4
+ TerminationPCode
+ TerminationNpaNxx
+ TransactionType
+ ServiceType
+ Date
+ Charge
+ CustomerType
+ Lines
+ Sale
+ Regulated
+ Minutes
+ Debit
+ ServiceClass
+ Lifeline
+ Facilities
+ Franchise
+ BusinessClass
+ CompanyIdentifier
+ CustomerNumber
+ InvoiceNumber
+ DiscountType
+ ExemptionType
+ AdjustmentMethod
+ Optional
+);
+
+$DEBUG = 2;
$TIMEOUT = 86400; # absolute time limit on waiting for a response file.
@@ -34,192 +76,238 @@ sub spooldir {
sub spoolname {
my $self = shift;
- my $conf = FS::Conf->new;;
my $spooldir = $self->spooldir;
mkdir $spooldir, 0700 unless -d $spooldir;
- my $basename = $conf->config('billsoft-company_code') .
+ my $upload = $self->spooldir . '/upload';
+ mkdir $upload, 0700 unless -d $upload;
+ my $basename = $self->conf->config('billsoft-company_code') .
time2str('%Y%m%d', time); # use the real clock time here
my $uniq = 'AA';
- while ( -e "$spooldir/$basename$uniq.CDF" ) {
+ while ( -e "$upload/$basename$uniq.CSV" ) {
$uniq++;
# these two letters must be unique within each day
}
- "$basename$uniq.CDF";
+ "$basename$uniq.CSV";
+}
+
+=item part_pkg_taxproduct PART_PKG, CLASSNUM
+
+Returns the taxproduct string (T-code and S-code concatenated) for
+PART_PKG with usage class CLASSNUM. CLASSNUM can be a numeric classnum,
+an empty string (for the package's base taxproduct), 'setup', or 'recur'.
+
+Returns undef if the package doesn't have a taxproduct.
+
+=cut
+
+sub part_pkg_taxproduct {
+ my ($self, $part_pkg, $classnum) = @_;
+ my $pkgpart = $part_pkg->get('pkgpart');
+ # all taxproducts
+ $self->{_taxproduct} ||= {};
+ # taxproduct(s) that are relevant to this package
+ my $pkg_taxproduct = $self->{_taxproduct}{$pkgpart} ||= {};
+ my $taxproduct; # return this
+ $classnum ||= '';
+ if (exists($pkg_taxproduct->{$classnum})) {
+ $taxproduct = $pkg_taxproduct->{$classnum};
+ } else {
+ my $part_pkg_taxproduct = $part_pkg->taxproduct($classnum);
+ $taxproduct = $pkg_taxproduct->{$classnum} = (
+ $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : undef
+ );
+ if (!$taxproduct) {
+ $self->log->error("part_pkg $pkgpart, class $_: taxproduct not found");
+ if ( !$self->conf->exists('ignore_incalculable_taxes') ) {
+ die "part_pkg $pkgpart, class $_: taxproduct not found\n";
+ }
+ }
+ }
+ warn "part_pkg $pkgpart, class $classnum: ".
+ ($taxproduct ?
+ "using taxproduct $taxproduct\n" :
+ "taxproduct not found\n")
+ if $DEBUG;
+ return $taxproduct;
}
-my $format =
- '%10s' . # Origination
- '%1s' . # Origination Flag (NPA-NXX)
- '%10s' . # Termination
- '%1s' . # Termination Flag (NPA-NXX)
- '%10s' . # Service Location
- '%1s' . # Service Location Flag (Pcode)
- '%1s' . # Customer Type ('B'usiness or 'R'esidential)
- '%8s' . # Invoice Date
- '+' . # Taxable Amount Sign
- '%011d' . # Taxable Amount (5 decimal places)
- '%6d' . # Lines
- '%6d' . # Locations
- '%12s' . # Transaction Type + Service Type
- '%1s' . # Client Resale Flag ('S'ale or 'R'esale)
- '%1s' . # Inc-Code ('I'n an incorporated city, or 'O'utside)
- ' ' . # Fed/State/County/Local Exempt
- '%1s' . # Primary Output Key, flag (our field)
- '%019d' . # Primary Output Key, numeric (our field)
- 'R' . # 'R'egulated (or 'U'nregulated)
- '%011d' . # Call Duration (tenths of minutes)
- 'C' . # Telecom Type ('C'alls, other things)
- '%1s' . # Service Class ('L'ocal, Long 'D'istance)
- ' NNC' . # non-lifeline, non-facilities based,
- # non-franchise, CLEC
- # (gross assumptions, may need a config option
- "\r\n"; # at least that's what was in the samples
+sub log {
+ my $self = shift;
+ return $self->{_log} ||= FS::Log->new('FS::TaxEngine::billsoft');
+}
+sub conf {
+ my $self = shift;
+ return $self->{_conf} ||= FS::Conf->new;
+}
sub create_batch {
my ($self, %opt) = @_;
+ my @invoices = qsearch('cust_bill', { pending => 'Y' });
+ $self->log->info(scalar(@invoices)." pending invoice(s) found.");
+ return if @invoices == 0;
+
$DB::single=1; # XXX
my $spooldir = $self->spooldir;
my $spoolname = $self->spoolname;
my $fh = IO::File->new();
- $fh->open("$spooldir/$spoolname", '>>');
+ $self->log->info("Starting batch in $spooldir/upload/$spoolname");
+ $fh->open("$spooldir/upload/$spoolname", '>');
$self->{fh} = $fh;
+ my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n" });
+ $csv->print($fh, \@input_cols);
+ $csv->column_names(\@input_cols);
+
# XXX limit based on freeside-daily custnum/agentnum options
# and maybe invoice date
- my @invoices = qsearch('cust_bill', { pending => 'Y' });
- warn scalar(@invoices)." pending invoice(s) found.\n";
foreach my $cust_bill (@invoices) {
my $invnum = $cust_bill->invnum;
my $cust_main = $cust_bill->cust_main;
- my $cust_type = $cust_main->company ? 'B' : 'R';
+ my $cust_type = $cust_main->taxstatus;
my $invoice_date = time2str('%Y%m%d', $cust_bill->_date);
+ my %bill_to = do {
+ my $location = $cust_main->bill_location;
+ my $zip = $location->zip;
+ my $plus4 = '';
+ if ($location->country eq 'US') {
+ ($zip, $plus4) = split(/-/, $zip);
+ }
+ ( BillToCountryISO => uc(country_code2code($location->country,
+ 'alpha-2' => 'alpha-3')),
+ BillToPCode => $location->geocode,
+ BillToZipCode => $zip,
+ BillToZipP4 => $plus4,
+ )
+ };
+
# cache some things
my (%cust_pkg, %part_pkg, %cust_location, %classname);
# keys are transaction codes (the first part of the taxproduct string)
# and then locationnums; for per-location taxes
my %sales;
+ my @options = $self->conf->config('billsoft-taxconfig');
+
+ my %bill_properties = (
+ %bill_to,
+ Date => $invoice_date,
+ CustomerType => $cust_type,
+ CustomerNumber => $cust_bill->custnum,
+ InvoiceNumber => $invnum,
+ Facilities => ($options[0] || ''),
+ Franchise => ($options[1] || ''),
+ Regulated => ($options[2] || ''),
+ BusinessClass => ($options[3] || ''),
+ );
+
foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) {
-$DB::single = 1;
my $cust_pkg = $cust_pkg{$cust_bill_pkg->pkgnum}
||= $cust_bill_pkg->cust_pkg;
my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
my $part_pkg = $part_pkg{$pkgpart} ||= FS::part_pkg->by_key($pkgpart);
- my $resale_mode = ($part_pkg->option('wholesale',1) ? 'R' : 'S');
- my $locationnum = $cust_pkg->locationnum;
- my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location;
- my %taxproduct; # CDR rated_classnum => taxproduct
+ my $resale_mode = ($part_pkg->option('wholesale',1) ? 'Resale' : 'Sale');
+ my %pkg_properties = (
+ %bill_properties,
+ Sale => $resale_mode,
+ Optional => $cust_bill_pkg->billpkgnum, # will be echoed
+ # others at this level? Lifeline?
+ # DiscountType may be relevant...
+ # and Proration
+ );
my $usage_total = 0;
- # go back to the original call details
- my $detailnums = FS::Record->scalar_sql(
- "SELECT array_to_string(array_agg(detailnum), ',') ".
- "FROM cust_bill_pkg_detail WHERE billpkgnum = ".
- $cust_bill_pkg->billpkgnum
- );
- # With summary details, even the number of CDRs returned from a single
- # invoice detail could be scary large. Avoid running out of memory.
- if (length $detailnums > 0) {
- my $cdr_search = FS::Cursor->new({
- 'table' => 'cdr',
- 'hashref' => { freesidestatus => 'done' },
- 'extra_sql' => "AND detailnum IN($detailnums)",
+ # cursorized joined search on the invoice details, for memory efficiency
+ my $cdr_search = FS::Cursor->new({
+ 'table' => 'cdr',
+ 'hashref' => { freesidestatus => 'done' },
+ 'addl_from' => ' JOIN cust_bill_pkg_detail USING (detailnum)',
+ 'extra_sql' => "AND cust_bill_pkg_detail.billpkgnum = ".
+ $cust_bill_pkg->billpkgnum
+ });
+
+ while (my $cdr = $cdr_search->fetch) {
+ my $classnum = $cdr->rated_classnum;
+ if ( $classnum ) {
+ $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname;
+ }
+
+ my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $classnum)
+ or next;
+ my $tcode = substr($taxproduct, 0, 6);
+ my $scode = substr($taxproduct, 6, 6);
+
+ # For CDRs, use the call termination site rather than setting
+ # Termination fields to the service address.
+ $csv->print_hr($fh, {
+ %pkg_properties,
+ RequestType => 'CalcTaxes',
+ OriginationNpaNxx => substr($cdr->src_lrn || $cdr->src, 0, 6),
+ TerminationNpaNxx => substr($cdr->dst_lrn || $cdr->dst, 0, 6),
+ TransactionType => $tcode,
+ ServiceType => $scode,
+ Charge => $cdr->rated_price,
+ Minutes => ($cdr->duration / 60.0), # floating point
});
- while (my $cdr = $cdr_search->fetch) {
- my $classnum = $cdr->rated_classnum;
- $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname
- if $classnum;
- $taxproduct{$classnum} ||= $part_pkg->taxproduct($classnum);
- if (!$taxproduct{$classnum}) {
- warn "part_pkg $pkgpart, class $classnum: ".
- ($taxproduct{$classnum} ?
- "using taxproduct ".$taxproduct{$classnum}->description."\n" :
- "taxproduct not found\n")
- if $DEBUG;
- next;
- }
-
- my $line = sprintf($format,
- substr($cdr->src, 0, 6), 'N',
- substr($cdr->dst, 0, 6), 'N',
- $location->geocode, 'P',
- $cust_type,
- $invoice_date,
- 100000 * $cdr->rated_price, # price (5 decimal places)
- 0, # lines
- 0, # locations
- $taxproduct{$classnum}->taxproduct,
- $resale_mode,
- ($location->incorporated ? 'I' : 'O'),
- 'C', # for Call
- $cdr->acctid,
- # Call duration (tenths of minutes)
- $cdr->duration / 6,
- # Service class indicator ('L'ocal, Long 'D'istance)
- # stupid hack
- (lc($classname{$classnum}) eq 'local' ? 'L' : 'D'),
- );
-
- print $fh $line;
-
- $usage_total += $cdr->rated_price;
-
- } # while $cdr = $cdr_search->fetch
- } # if @$detailnums; otherwise there are no usage details for this line
+ $usage_total += $cdr->rated_price;
+
+ } # while $cdr = $cdr_search->fetch
my $recur_tcode;
# now write lines for the non-CDR portion of the charges
+
+ my $locationnum = $cust_pkg->locationnum;
+
+ # use termination address for the service location
+ my %termination = do {
+ my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location;
+ my $zip = $location->zip;
+ my $plus4 = '';
+ if ($location->country eq 'US') {
+ ($zip, $plus4) = split(/-/, $zip);
+ }
+ ( TerminationCountryISO => uc(country_code2code($location->country,
+ 'alpha-2' => 'alpha-3')),
+ TerminationPCode => $location->geocode,
+ TerminationZipCode => $zip,
+ TerminationZipP4 => $plus4,
+ )
+ };
+
foreach (qw(setup recur)) {
- my $taxproduct = $part_pkg->taxproduct($_);
- warn "part_pkg $pkgpart, class $_: ".
- ($taxproduct ?
- "using taxproduct ".$taxproduct->description."\n" :
- "taxproduct not found\n")
- if $DEBUG;
+ my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $_);
next unless $taxproduct;
- my ($tcode) = $taxproduct->taxproduct =~ /^(\d{6})/;
- $sales{$tcode} ||= {};
- $sales{$tcode}{$location->locationnum} ||= 0;
+ my $tcode = substr($taxproduct, 0, 6);
+ my $scode = substr($taxproduct, 6, 6);
+ $sales{$tcode} ||= 0;
$recur_tcode = $tcode if $_ eq 'recur';
my $price = $cust_bill_pkg->get($_);
- $sales{$tcode}{$location->locationnum} += $price;
+ $sales{$tcode} += $price;
$price -= $usage_total if $_ eq 'recur';
- my $line = sprintf($format,
- $location->geocode, 'P', # all 3 locations the same
- $location->geocode, 'P',
- $location->geocode, 'P',
- $cust_type,
- $invoice_date,
- 100000 * $price, # price (5 decimal places)
- 0, # lines
- 0, # locations
- $taxproduct->taxproduct,
- $resale_mode,
- ($location->incorporated ? 'I' : 'O'),
- substr(uc($_), 0, 1), # 'S'etup or 'R'ecur
- $cust_bill_pkg->billpkgnum,
- 0, # call duration
- 'D' # service class indicator
- );
-
- print $fh $line;
+ $csv->print_hr($fh, {
+ %pkg_properties,
+ %termination,
+ RequestType => 'CalcTaxes',
+ TransactionType => $tcode,
+ ServiceType => $scode,
+ Charge => $price,
+ } );
} # foreach (setup, recur)
- # S-code 23: taxes based on number of lines (E911, mostly)
+ # S-code 21: taxes based on number of lines (E911, mostly)
# voip_cdr and voip_inbound packages know how to report this. Not all
- # T-codes are eligible for this; only report it if the /23 taxproduct
+ # T-codes are eligible for this; only report it if the /21 taxproduct
# exists.
#
# (note: the nomenclature of "service" and "transaction" codes is
@@ -229,93 +317,49 @@ $DB::single = 1;
# reverse. I recommend calling them "S" and "T" codes internally just
# to avoid confusion.)
- my $lines_taxproduct = qsearchs('part_pkg_taxproduct', {
- 'taxproduct' => sprintf('%06d%06d', $recur_tcode, 21)
- });
- my $lines = $cust_bill_pkg->units;
-
- if ( $lines_taxproduct and $lines ) {
-
- my $line = sprintf($format,
- $location->geocode, 'P', # all 3 locations the same
- $location->geocode, 'P',
- $location->geocode, 'P',
- $cust_type,
- $invoice_date,
- 0, # price (5 decimal places)
- $lines, # lines
- 0, # locations
- $lines_taxproduct->taxproduct,
- $resale_mode,
- ($location->incorporated ? 'I' : 'O'),
- 'L', # 'L'ines
- $cust_bill_pkg->billpkgnum,
- 0, # call duration
- 'D' # service class indicator
+ # XXX cache me
+ # XXX this isn't precisely correct. Local exchange service on
+ # high-capacity trunks, Centrex, and PBX trunks are supposed to be
+ # reported as three separate implicit transactions: number of trunks,
+ # of outbound channels, of extensions.
+ # This is also true for VoIP PBX trunks. Come back to this.
+ if ( $recur_tcode ) {
+ my $lines_taxproduct = FS::part_pkg_taxproduct->count(
+ 'data_vendor = \'billsoft\' and taxproduct = ?',
+ sprintf('%06d%06d', $recur_tcode, 21)
);
-
+ my $lines = $cust_bill_pkg->units;
+
+ if ( $lines_taxproduct and $lines ) {
+ $csv->print_hr($fh, {
+ %pkg_properties,
+ %termination,
+ RequestType => 'CalcTaxes',
+ TransactionType => $recur_tcode,
+ ServiceType => 21,
+ Charge => 0,
+ Lines => $lines,
+ } );
+ }
}
} # foreach my $cust_bill_pkg
- # Implicit transactions
foreach my $tcode (keys %sales) {
- # S-code 23: number of locations (rare)
- my $locations_taxproduct =
- qsearchs('part_pkg_taxproduct', {
- 'taxproduct' => sprintf('%06d%06d', $tcode, 23)
- });
-
- if ( $locations_taxproduct and keys %{ $sales{$tcode} } > 0 ) {
- my $location = $cust_main->bill_location;
- my $line = sprintf($format,
- $location->geocode, 'P', # all 3 locations the same
- $location->geocode, 'P',
- $location->geocode, 'P',
- $cust_type,
- $invoice_date,
- 0, # price (5 decimal places)
- 0, # lines
- keys(%{ $sales{$tcode} }),# locations
- $locations_taxproduct->taxproduct,
- 'S',
- ($location->incorporated ? 'I' : 'O'),
- 'O', # l'O'cations
- sprintf('%07d%06d%06d', $invnum, $tcode, 0),
- 0, # call duration
- 'D' # service class indicator
- );
-
- print $fh $line;
- }
-
# S-code 43: per-invoice tax (apparently this is a thing)
- my $invoice_taxproduct =
- qsearchs('part_pkg_taxproduct', {
- 'taxproduct' => sprintf('%06d%06d', $tcode, 43)
- });
+ my $invoice_taxproduct = FS::part_pkg_taxproduct->count(
+ 'data_vendor = \'billsoft\' and taxproduct = ?',
+ sprintf('%06d%06d', $tcode, 43)
+ );
if ( $invoice_taxproduct ) {
- my $location = $cust_main->bill_location;
- my $line = sprintf($format,
- $location->geocode, 'P', # all 3 locations the same
- $location->geocode, 'P',
- $location->geocode, 'P',
- $cust_type,
- $invoice_date,
- 0, # price (5 decimal places)
- 0, # lines
- 0, # locations
- $invoice_taxproduct->taxproduct,
- 'S', # resale mode
- ($location->incorporated ? 'I' : 'O'),
- 'I', # 'I'nvoice tax
- sprintf('%07d%06d%06d', $invnum, $tcode, 0),
- 0, # call duration
- 'D' # service class indicator
- );
-
- print $fh $line;
+ $csv->print_hr($fh, {
+ RequestType => 'CalcTaxes',
+ %bill_properties,
+ TransactionType => $tcode,
+ ServiceType => 43,
+ Charge => 0,
+ } );
}
} # foreach $tcode
} # foreach $cust_bill
@@ -332,6 +376,7 @@ sub cust_tax_locations {
}
my $zip = $location->zip;
return () unless $location->country eq 'US';
+ return () unless $zip;
# currently the only one supported
if ( $zip =~ /^(\d{5})(-\d{4})?$/ ) {
$zip = $1;
@@ -363,8 +408,12 @@ sub transfer_batch {
my $target = qsearchs('upload_target', { hostname => 'ftp.billsoft.com' })
or die "No Billsoft upload target defined.\n";
+ local $CWD = $self->spooldir . '/upload';
# create the batch
- my $upload = $self->create_batch(%opt);
+ my $upload = $self->create_batch(%opt); # name of the CSV file
+ # returns undef if there were no pending invoices; in that case
+ # skip the rest of this procedure
+ return if !$upload;
# upload it
my $ftp = $target->connect;
@@ -372,28 +421,33 @@ sub transfer_batch {
die "Error connecting to Billsoft FTP server:\n$ftp\n";
}
my $fh = IO::File->new();
- warn "Processing: $upload\n";
+ $self->log->info("Processing: $upload");
+ if ( stat('FTP.ZIP') ) {
+ unlink('FTP.ZIP') or die "Failed to remove old tax batch:\n$!\n";
+ }
my $error = system("zip -j -o FTP.ZIP $upload");
die "Failed to compress tax batch\n$!\n" if $error;
- warn "Uploading file...\n";
+ $self->log->debug("Uploading file");
$ftp->put('FTP.ZIP');
+ unlink('FTP.ZIP');
+ local $CWD = $self->spooldir;
my $download = $upload;
- # naming convention for these is: same as the CDF contained in the
+ # naming convention for these is: same as the CSV contained in the
# zip file, but with an "R" inserted after the company ID prefix
- $download =~ s/^(...)(\d{8}..).CDF/$1R$2.ZIP/;
- warn "Waiting for output file ($download)...\n";
+ $download =~ s/^(...)(\d{8}..).CSV/$1R$2.ZIP/;
+ $self->log->debug("Waiting for output file ($download)");
my $starttime = time;
my $downloaded = 0;
while ( time - $starttime < $TIMEOUT ) {
my @ls = $ftp->ls($download);
if ( @ls ) {
if ($ftp->get($download, "download/$download")) {
- warn "Downloaded '$download'.\n";
+ $self->log->debug("Downloaded '$download'");
$downloaded = 1;
last;
} else {
- warn "Failed to download '$download': ".$ftp->message."\n";
+ $self->log->warn("Failed to download '$download': ".$ftp->message);
# We know the file exists, so continue trying to download it.
# Maybe the problem will get fixed.
}
@@ -401,21 +455,21 @@ sub transfer_batch {
sleep 30;
}
if (!$downloaded) {
- warn "No output file received.\n";
+ $self->log->error("No output file received.");
next BATCH;
}
- warn "Decompressing...\n";
+ $self->log->debug("Decompressing...");
system("unzip -o download/$download");
- foreach my $csf (glob "*.CSF") {
- warn "Processing '$csf'...\n";
- $fh->open($csf, '<') or die "failed to open downloaded file $csf";
+ my $output = $upload;
+ $output =~ s/.CSV$/_dtl.rpt.csv/i;
+ if ([ -f $output ]) {
+ $self->log->info("Processing '$output'");
+ $fh->open($output, '<') or die "failed to open downloaded file $output";
$self->batch_import($fh); # dies on error
$fh->close;
- unlink $csf unless $DEBUG;
+ unlink $output unless $DEBUG;
}
unlink 'FTP.ZIP';
- move($upload, "upload/$upload");
- warn "Finished.\n";
$dbh->commit if $oldAutoCommit;
return;
}
@@ -433,91 +487,93 @@ sub batch_import {
}
my $href;
- my $parser = Parse::FixedLength->new(
- [
- # key => 20, # for our purposes we split it up
- flag => 1,
- pkey => 19,
- taxtype => 6,
- authority => 1,
- sign => 1,
- amount => 11,
- pcode => 9,
- ],
- );
-
- # start parsing the input file
+ my $parser = Text::CSV_XS->new({binary => 1});
+ # set column names from header row
+ $parser->column_names($parser->getline($fh));
+
+ # start parsing the file
my $errors = 0;
my $row = 1;
- foreach my $line (<$fh>) {
- warn $line if $DEBUG > 1;
- %$href = ();
- $href = $parser->parse($line);
- # convert some of these to integers
- $href->{$_} += 0 foreach(qw(pkey taxtype amount pcode));
- next if $href->{amount} == 0; # then nobody cares
-
- my $flag = $href->{flag};
- my $pkey = $href->{pkey};
+ # the file is functionally a left join of submitted line items with their
+ # taxes; if a line item has no taxes then it will produce an output row
+ # with all the tax fields empty.
+ while ($href = $parser->getline_hr($fh)) {
+ next if $href->{TaxTypeID} eq ''; # then this row has no taxes
+ next if $href->{TaxAmount} == 0; # then the calculated tax is zero
+
+ my $billpkgnum = $href->{Optional};
+ my $invnum = $href->{InvoiceNumber};
my $cust_bill_pkg; # the line item that this tax applies to
- # resolve the taxable object
- if ( $flag eq 'C' ) {
- # this line represents a CDR.
- my $cdr = FS::cdr->by_key($pkey);
- if (!$cdr) {
- warn "[$row]\tCDR #$pkey not found.\n";
- } elsif (!$cdr->detailnum) {
- warn "[$row]\tCDR #$pkey has not been billed.\n";
+ if ( !exists($self->{cust_bill}->{$invnum}) ) {
+ $self->log->error("invoice #$invnum invoice not in pending state");
+ $errors++;
+ next;
+ }
+ if ( $billpkgnum ) {
+ $cust_bill_pkg = FS::cust_bill_pkg->by_key($billpkgnum);
+ if ( $cust_bill_pkg->invnum != $invnum ) {
+ $self->log->error("invoice #$invnum invoice number mismatch");
$errors++;
next;
- } else {
- my $detail = FS::cust_bill_pkg_detail->by_key($cdr->detailnum);
- $cust_bill_pkg = $detail->cust_bill_pkg;
- }
- } elsif ( $flag =~ /S|R|L/ ) {
- # this line represents a setup or recur fee, or a number of lines.
- $cust_bill_pkg = FS::cust_bill_pkg->by_key($pkey);
- if (!$cust_bill_pkg) {
- warn "[$row]\tLine item #$pkey not found.\n";
}
- } elsif ( $flag =~ /O|I/ ) {
- warn "Per-invoice taxes are not implemented.\n";
} else {
- warn "[$row]\tFlag '$flag' not recognized.\n";
- }
- if (!$cust_bill_pkg) {
- $errors++; # this will trigger a rollback of the transaction
- next;
+ $cust_bill_pkg = ($self->{cust_bill}->{$invnum}->cust_bill_pkg)[0];
+ $billpkgnum = $cust_bill_pkg->billpkgnum;
}
+
# resolve the tax definition
# base name of the tax type (like "Sales Tax" or "Universal Lifeline
# Telephone Service Charge").
- my $tax_class = $TAX_CLASSES{ $href->{taxtype} + 0 };
+ my $tax_class = $TAX_CLASSES{ $href->{TaxTypeID} };
if (!$tax_class) {
- warn "[$row]\tUnknown tax type $href->{taxtype}.\n";
- $errors++;
- next;
+ $self->log->warn("Unknown tax type $href->{TaxTypeID}");
+ $tax_class = FS::tax_class->new({
+ 'data_vendor' => 'billsoft',
+ 'taxclass' => $href->{TaxTypeID},
+ 'description' => $href->{TaxType}
+ });
+ my $error = $tax_class->insert;
+ if ($error) {
+ $self->log->error("Failed to insert tax_class record: $error");
+ $errors++;
+ next;
+ }
+ $TAX_CLASSES{ $href->{TaxTypeID} } = $tax_class;
}
my $itemdesc = uc($tax_class->description);
- my $location = qsearchs('tax_rate_location',
- { geocode => $href->{pcode} }
- );
+ my $location = qsearchs('tax_rate_location', {
+ data_vendor => 'billsoft',
+ disabled => '',
+ geocode => $href->{PCode}
+ });
if (!$location) {
- warn "Unknown tax authority location ".$href->{pcode}."\n";
- $errors++;
- next;
+ $location = FS::tax_rate_location->new({
+ 'data_vendor' => 'billsoft',
+ 'geocode' => $href->{PCode},
+ 'country' => uc(country_code2code($href->{CountryISO},
+ 'alpha-3' => 'alpha-2')),
+ 'state' => $href->{State},
+ 'county' => $href->{County},
+ 'city' => $href->{Locality},
+ });
+ my $error = $location->insert;
+ if ($error) {
+ $self->log->error("Failed to insert tax_class record: $error");
+ $errors++;
+ next;
+ }
}
# jurisdiction name
my $prefix = '';
- if ( $href->{authority} == 0 ) { # national-level tax
+ if ( $href->{TaxLevelID} == 0 ) { # national-level tax
# do nothing
- } elsif ( $href->{authority} == 1 ) {
+ } elsif ( $href->{TaxLevelID} == 1 ) {
$prefix = $location->state;
- } elsif ( $href->{authority} == 2 ) {
+ } elsif ( $href->{TaxLevelID} == 2 ) {
$prefix = $location->county . ' COUNTY';
- } elsif ( $href->{authority} == 3 ) {
+ } elsif ( $href->{TaxLevelID} == 3 ) {
$prefix = $location->city;
- } elsif ( $href->{authority} == 4 ) { # unincorporated area ta
+ } elsif ( $href->{TaxLevelID} == 4 ) { # unincorporated area ta
# do nothing
}
# Some itemdescs start with the jurisdiction name; otherwise, prepend
@@ -528,15 +584,14 @@ sub batch_import {
# Create or locate a tax_rate record, because we need one to foreign-key
# the cust_bill_pkg_tax_rate_location record.
my $tax_rate = $self->find_or_insert_tax_rate(
- geocode => $href->{pcode},
+ geocode => $href->{PCode},
taxclassnum => $tax_class->taxclassnum,
taxname => $itemdesc,
);
- # Convert amount from 10^-5 dollars to dollars/cents
- my $amount = sprintf('%.2f', $href->{amount} / 100000);
+ my $amount = sprintf('%.2f', $href->{TaxAmount});
# and add it to the tax under this name
my $tax_item = $self->add_tax_item(
- invnum => $cust_bill_pkg->invnum,
+ invnum => $invnum,
itemdesc => $itemdesc,
amount => $amount,
);
@@ -547,7 +602,7 @@ sub batch_import {
taxtype => 'FS::tax_rate',
taxratelocationnum => $location->taxratelocationnum,
amount => $amount,
- taxable_billpkgnum => $cust_bill_pkg->billpkgnum,
+ taxable_billpkgnum => $billpkgnum,
});
my $error = $subitem->insert;
die "Error linking tax to taxable item: $error\n" if $error;
diff --git a/FS/FS/TicketSystem.pm b/FS/FS/TicketSystem.pm
index 8f3d7af03..c973c8802 100644
--- a/FS/FS/TicketSystem.pm
+++ b/FS/FS/TicketSystem.pm
@@ -401,6 +401,25 @@ sub _upgrade_data {
warn "Fixed $rows transactions with empty time values\n" if $rows > 0;
}
+ # One-time fix: We've created a "BulkUpdateTickets" access right; grant
+ # it to all auth'd users initially.
+ eval "use FS::upgrade_journal;";
+ my $upgrade = 'RT_add_BulkUpdateTickets_ACL';
+ if (!FS::upgrade_journal->is_done($upgrade)) {
+ my $groups = RT::Groups->new(RT->SystemUser);
+ $groups->LimitToEnabled;
+ $groups->LimitToSystemInternalGroups;
+ $groups->Limit(FIELD => 'Type', VALUE => 'Privileged', OPERATOR => '=');
+ my $group = $groups->First
+ or die "No RT internal group found for Privileged users";
+ my ($val, $msg) = $group->PrincipalObj->GrantRight(
+ Right => 'BulkUpdateTickets', Object => RT->System
+ );
+ die "Couldn't grant BulkUpdateTickets right to all users: $msg\n"
+ if !$val;
+ FS::upgrade_journal->set_done($upgrade);
+ }
+
return;
}
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index 0113bf92a..41349a59a 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -49,7 +49,7 @@ sub upgrade_config {
# to simplify tokenization upgrades
die "Conf selfservice-payment_gateway no longer supported"
- if conf->config('selfservice-payment_gateway');
+ if $conf->config('selfservice-payment_gateway');
$conf->touch('payment_receipt')
if $conf->exists('payment_receipt_email')
@@ -362,7 +362,11 @@ sub upgrade_data {
#fix whitespace - before cust_main
'cust_location' => [],
- #cust_main (remove paycvv from history, locations, cust_payby, etc)
+ # need before cust_main tokenization upgrade,
+ # blocks tokenization upgrade if deprecated features still in use
+ 'agent_payment_gateway' => [],
+
+ #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
'cust_main' => [],
#contact -> cust_contact / prospect_contact
@@ -390,10 +394,6 @@ sub upgrade_data {
#duplicate history records
'h_cust_svc' => [],
- # need before transaction tables,
- # blocks tokenization upgrade if deprecated features still in use
- 'agent_payment_gateway' => [],
-
#populate cust_pay.otaker
'cust_pay' => [],
diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm
index 8aa78c2b7..e70b9716a 100644
--- a/FS/FS/agent.pm
+++ b/FS/FS/agent.pm
@@ -9,6 +9,7 @@ use FS::cust_main;
use FS::cust_pkg;
use FS::reg_code;
use FS::agent_payment_gateway;
+use FS::payment_gateway;
use FS::TicketSystem;
use FS::Conf;
@@ -238,8 +239,7 @@ sub ticketing_queue {
Returns a payment gateway object (see L<FS::payment_gateway>) for this agent.
-Currently available options are I<nofatal>, I<method>, I<thirdparty>,
-<conf> and I<load_gatewaynum>.
+Currently available options are I<nofatal>, I<method>, I<thirdparty> and I<conf>.
If I<nofatal> is set, and no gateway is available, then the empty string
will be returned instead of throwing a fatal exception.
@@ -253,12 +253,7 @@ the business-onlinepayment-ach gateway will be returned if available.
If I<thirdparty> is set and the I<method> is PAYPAL, the defined paypal
gateway will be returned.
-If I<load_gatewaynum> exists, then either the specified gateway or the
-default gateway will be returned. Agent overrides are ignored, and this can
-safely be called as a class method if this option is specified. Not
-compatible with I<thirdparty>.
-
-Exsisting I<$conf> may be passed for efficiency.
+Exisisting I<$conf> may be passed for efficiency.
=cut
@@ -268,8 +263,8 @@ Exsisting I<$conf> may be passed for efficiency.
sub payment_gateway {
my ( $self, %options ) = @_;
+ $options{'conf'} ||= new FS::Conf;
my $conf = $options{'conf'};
- $conf ||= new FS::Conf;
if ( $options{thirdparty} ) {
@@ -299,72 +294,12 @@ sub payment_gateway {
}
}
- my ($override, $payment_gateway);
- if (exists $options{'load_gatewaynum'}) { # no agent overrides if this opt is in use
- if ($options{'load_gatewaynum'}) {
- $payment_gateway = qsearchs('payment_gateway', { gatewaynumnum => $options{'load_gatewaynum'} } );
- # always fatal
- die "Could not load payment gateway ".$options{'load_gatewaynum'} unless $payment_gateway;
- } # else use default, loaded below
- } else {
- $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } );
- }
-
- if ( $override ) { #use a payment gateway override
-
- $payment_gateway = $override->payment_gateway;
-
- $payment_gateway->gateway_namespace('Business::OnlinePayment')
- unless $payment_gateway->gateway_namespace;
-
- } elsif (!$payment_gateway) { #use the standard settings from the config
-
- # the standard settings from the config could be moved to a null agent
- # agent_payment_gateway referenced payment_gateway
-
- # remember, this block might be run as a class method if false load_gatewaynum exists
+ my $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } );
- unless ( $conf->exists('business-onlinepayment') ) {
- if ( $options{'nofatal'} ) {
- return '';
- } else {
- die "Real-time processing not enabled\n";
- }
- }
-
- #load up config
- my $bop_config = 'business-onlinepayment';
- $bop_config .= '-ach'
- if ( $options{method}
- && $options{method} =~ /^(ECHECK|CHEK)$/
- && $conf->exists($bop_config. '-ach')
- );
- my ( $processor, $login, $password, $action, @bop_options ) =
- $conf->config($bop_config);
- $action ||= 'normal authorization';
- pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
- die "No real-time processor is enabled - ".
- "did you set the business-onlinepayment configuration value?\n"
- unless $processor;
-
- $payment_gateway = new FS::payment_gateway;
-
- $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
- 'Business::OnlinePayment');
- $payment_gateway->gateway_module($processor);
- $payment_gateway->gateway_username($login);
- $payment_gateway->gateway_password($password);
- $payment_gateway->gateway_action($action);
- $payment_gateway->set('options', [ @bop_options ]);
-
- }
-
- unless ( $payment_gateway->gateway_namespace ) {
- $payment_gateway->gateway_namespace(
- scalar($conf->config('business-onlinepayment-namespace'))
- || 'Business::OnlinePayment'
- );
- }
+ my $payment_gateway = FS::payment_gateway->by_key_or_default(
+ gatewaynum => $override ? $override->gatewaynum : '',
+ %options,
+ );
$payment_gateway;
}
diff --git a/FS/FS/agent_payment_gateway.pm b/FS/FS/agent_payment_gateway.pm
index 4991c1912..6a7cc06d1 100644
--- a/FS/FS/agent_payment_gateway.pm
+++ b/FS/FS/agent_payment_gateway.pm
@@ -1,5 +1,6 @@
package FS::agent_payment_gateway;
use base qw(FS::Record);
+use FS::Record qw( qsearch );
use strict;
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 747776b26..51bde33fa 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -5356,6 +5356,11 @@ sub _upgrade_data { #class method
}
+sub queueable_upgrade {
+ my $class = shift;
+ FS::cust_main::Billing_Realtime::token_check(@_);
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm
index d57be11ab..35293f0ea 100644
--- a/FS/FS/cust_main/Billing_Realtime.pm
+++ b/FS/FS/cust_main/Billing_Realtime.pm
@@ -14,6 +14,7 @@ use FS::cust_pay_pending;
use FS::cust_bill_pay;
use FS::cust_refund;
use FS::banned_pay;
+use FS::payment_gateway;
$realtime_bop_decline_quiet = 0;
@@ -112,7 +113,6 @@ I<depend_jobnum> allows payment capture to unlock export jobs
=cut
# Currently only used by ClientAPI
-# NOT 4.x COMPATIBLE (see below)
sub realtime_collect {
my( $self, %options ) = @_;
@@ -126,10 +126,6 @@ sub realtime_collect {
$options{amount} = $self->balance unless exists( $options{amount} );
return '' unless $options{amount} > 0;
- #### NOT 4.x COMPATIBLE
- $options{method} = FS::payby->payby2bop($self->payby)
- unless exists( $options{method} );
-
return $self->realtime_bop({%options});
}
@@ -223,6 +219,7 @@ sub _bop_recurring_billing {
}
+#can run safely as class method if opt payment_gateway already exists
sub _payment_gateway {
my ($self, $options) = @_;
@@ -239,8 +236,9 @@ sub _payment_gateway {
$options->{payment_gateway};
}
+# not a method!!!
sub _bop_auth {
- my ($self, $options) = @_;
+ my ($options) = @_;
(
'login' => $options->{payment_gateway}->gateway_username,
@@ -282,8 +280,9 @@ sub _bop_defaults {
}
+# not a method!
sub _bop_cust_payby_options {
- my ($self,$options) = @_;
+ my ($options) = @_;
my $cust_payby = $options->{'cust_payby'};
if ($cust_payby) {
@@ -319,6 +318,8 @@ sub _bop_cust_payby_options {
}
}
+# can be called as class method,
+# but can't load default name/phone fields as class method
sub _bop_content {
my ($self, $options) = @_;
my %content = ();
@@ -339,16 +340,16 @@ sub _bop_content {
/^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
or return "Illegal payname $payname";
($payfirst, $paylast) = ($1, $2);
- } else {
+ } elsif (ref($self)) { # can't set payname if called as class method
$payfirst = $self->getfield('first');
$paylast = $self->getfield('last');
$payname = "$payfirst $paylast";
}
- $content{last_name} = $paylast;
- $content{first_name} = $payfirst;
+ $content{last_name} = $paylast if $paylast;
+ $content{first_name} = $payfirst if $payfirst;
- $content{name} = $payname;
+ $content{name} = $payname if $payname;
$content{address} = $options->{'address1'};
my $address2 = $options->{'address2'};
@@ -359,7 +360,9 @@ sub _bop_content {
$content{zip} = $options->{'zip'};
$content{country} = $options->{'country'};
- $content{phone} = $self->daytime || $self->night;
+ # can't set phone if called as class method
+ $content{phone} = $self->daytime || $self->night
+ if ref($self);
my $currency = $conf->exists('business-onlinepayment-currency')
&& $conf->config('business-onlinepayment-currency');
@@ -369,6 +372,7 @@ sub _bop_content {
}
# updates payinfo and cust_payby options with token from transaction
+# can be called as a class method
sub _tokenize_card {
my ($self,$transaction,$options) = @_;
if ( $transaction->can('card_token')
@@ -410,7 +414,7 @@ sub realtime_bop {
}
# set fields from passed cust_payby
- $self->_bop_cust_payby_options(\%options);
+ _bop_cust_payby_options(\%options);
# possibly run a separate transaction to tokenize card number,
# so that we never store tokenized card info in cust_pay_pending
@@ -423,8 +427,6 @@ sub realtime_bop {
$token_error = $options{'cust_payby'}->replace;
return $token_error if $token_error;
}
- return "Cannot tokenize card info"
- if $conf->exists('no_saved_cardnumbers') && !$self->tokenized($options{'payinfo'});
}
###
@@ -698,7 +700,7 @@ sub realtime_bop {
$transaction->content(
'type' => $options{method},
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => $action1,
'description' => $options{'description'},
'amount' => $options{amount},
@@ -760,7 +762,7 @@ sub realtime_bop {
%content,
type => $options{method},
action => $action2,
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
order_number => $ordernum,
amount => $options{amount},
authorization => $auth,
@@ -1291,7 +1293,7 @@ sub realtime_botpp_capture {
$transaction->content(
'type' => $method,
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => 'Post Authorization',
'description' => $options{'description'},
'amount' => $cust_pay_pending->paid,
@@ -1478,10 +1480,12 @@ sub realtime_refund_bop {
@bop_options = $payment_gateway->gatewaynum
? $payment_gateway->options
: @{ $payment_gateway->get('options') };
+ my %bop_options = @bop_options;
return "processor of payment $options{'paynum'} $processor does not".
" match default processor $conf_processor"
- unless $processor eq $conf_processor;
+ unless ($processor eq $conf_processor)
+ || (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}));
}
@@ -1490,9 +1494,7 @@ sub realtime_refund_bop {
# like a normal transaction
my $payment_gateway =
- $self->agent->payment_gateway( 'method' => $options{method},
- #'payinfo' => $payinfo,
- );
+ $self->agent->payment_gateway( 'method' => $options{method} );
my( $processor, $login, $password, $namespace ) =
map { my $method = "gateway_$_"; $payment_gateway->$method }
qw( module username password namespace );
@@ -1625,18 +1627,22 @@ sub realtime_refund_bop {
if length($payip);
my $payinfo = '';
+ my $paymask = ''; # for refund record
if ( $options{method} eq 'CC' ) {
if ( $cust_pay ) {
$content{card_number} = $payinfo = $cust_pay->payinfo;
+ $paymask = $cust_pay->paymask;
(exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
=~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
($content{expiration} = "$2/$1"); # where available
} else {
- $content{card_number} = $payinfo = $self->payinfo;
- (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
- =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
- $content{expiration} = "$2/$1";
+ # this really needs a better cleanup
+ die "Refund without paynum not supported";
+# $content{card_number} = $payinfo = $self->payinfo;
+# (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
+# =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+# $content{expiration} = "$2/$1";
}
} elsif ( $options{method} eq 'ECHECK' ) {
@@ -1700,6 +1706,7 @@ sub realtime_refund_bop {
'_date' => '',
'payby' => $bop_method2payby{$options{method}},
'payinfo' => $payinfo,
+ 'paymask' => $paymask,
'reasonnum' => $options{'reasonnum'},
'gatewaynum' => $gatewaynum, # may be null
'processor' => $processor,
@@ -1764,7 +1771,7 @@ sub realtime_verify_bop {
# set fields from passed cust_payby
return "No cust_payby" unless $options{'cust_payby'};
- $self->_bop_cust_payby_options(\%options);
+ _bop_cust_payby_options(\%options);
# possibly run a separate transaction to tokenize card number,
# so that we never store tokenized card info in cust_pay_pending
@@ -1773,8 +1780,6 @@ sub realtime_verify_bop {
return $token_error if $token_error;
#important that we not replace cust_payby here,
#because cust_payby->replace uses realtime_verify_bop!
- return "Cannot tokenize card info"
- if $conf->exists('no_saved_cardnumbers') && !$self->tokenized($options{'payinfo'});
}
###
@@ -1911,7 +1916,7 @@ sub realtime_verify_bop {
$transaction->content(
'type' => 'CC',
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => 'Authorization Only',
'description' => $options{'description'},
'amount' => '1.00',
@@ -1958,7 +1963,7 @@ sub realtime_verify_bop {
);
$reverse->content( 'action' => 'Reverse Authorization',
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
# B:OP
'amount' => '1.00',
@@ -2177,8 +2182,13 @@ Otherwise, options I<method>, I<payinfo> and other cust_payby fields
may be passed. If options are passed as a hashref, I<payinfo>
will be updated as appropriate in the passed hashref.
+Can be run as a class method if option I<payment_gateway> is passed,
+but default customer id/name/phone can't be set in that case. This
+is really only intended for tokenizing old records on upgrade.
+
=cut
+# careful--might be run as a class method
sub realtime_tokenize {
my $self = shift;
@@ -2196,7 +2206,7 @@ sub realtime_tokenize {
}
# set fields from passed cust_payby
- $self->_bop_cust_payby_options(\%options);
+ _bop_cust_payby_options(\%options);
return '' unless $options{method} eq 'CC';
return '' if $self->tokenized($options{payinfo}); #already tokenized
@@ -2241,6 +2251,11 @@ sub realtime_tokenize {
# massage data
###
+ ### Currently, cardfortress only keys in on card number and exp date.
+ ### We pass everything we'd pass to a normal transaction,
+ ### for ease of current and future development,
+ ### but note, when tokenizing old records, we may only have access to payinfo/paydate
+
my $bop_content = $self->_bop_content(\%options);
return $bop_content unless ref($bop_content);
@@ -2264,6 +2279,9 @@ sub realtime_tokenize {
my $payissue = $options{'payissue'};
$content{issue_number} = $payissue if $payissue;
+ $content{customer_id} = $self->custnum
+ if ref($self);
+
###
# run transaction
###
@@ -2274,10 +2292,9 @@ sub realtime_tokenize {
$transaction->content(
'type' => 'CC',
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => 'Tokenize',
'description' => $options{'description'},
- 'customer_id' => $self->custnum,
%$bop_content,
%content, #after
);
@@ -2315,7 +2332,9 @@ sub realtime_tokenize {
Convenience wrapper for L<FS::payinfo_Mixin/tokenized>
-PAYINFO is required
+PAYINFO is required.
+
+Can be run as class or object method, never loads from object.
=cut
@@ -2325,7 +2344,7 @@ sub tokenized {
FS::cust_pay->tokenized($payinfo);
}
-=item token_check
+=item token_check [ quiet => 1, queue => 1, daily => 1 ]
NOT A METHOD. Acts on all customers. Placed here because it makes
use of module-internal methods, and to keep everything that uses
@@ -2334,74 +2353,138 @@ Billing::OnlinePayment all in one place.
Tokenizes all tokenizable card numbers from payinfo in cust_payby and
CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund.
-If all configured gateways have the ability to tokenize, then detection of
-an untokenizable record will cause a fatal error.
+If the I<queue> flag is set, newly tokenized records will be immediately
+committed, regardless of AutoCommit, so as to release the mutex on the record.
+
+If all configured gateways have the ability to tokenize, detection of an
+untokenizable record will cause a fatal error. However, if the I<queue> flag
+is set, this will instead cause a critical error to be recorded in the log,
+and any other tokenizable records will still be committed.
+
+If the I<daily> flag is also set, detection of existing untokenized records will
+record a critical error in the system log (because they should have never appeared
+in the first place.) Tokenization will still be attempted.
+
+If any configured gateways do NOT have the ability to tokenize, or if a
+default gateway is not configured, then untokenized records are not considered
+a threat, and no critical errors will be generated in the log.
=cut
sub token_check {
- # no input, acts on all customers
+ #acts on all customers
+ my %opt = @_;
+ my $debug = !$opt{'quiet'} || $DEBUG;
- eval "use FS::Cursor";
- return "Error initializing FS::Cursor: ".$@ if $@;
+ warn "token_check called with opts\n".Dumper(\%opt) if $debug;
- my $dbh = dbh;
+ # force some explicitness when invoking this method
+ die "token_check must run with queue flag if run with daily flag"
+ if $opt{'daily'} && !$opt{'queue'};
+
+ my $conf = FS::Conf->new;
+
+ my $log = FS::Log->new('FS::cust_main::Billing_Realtime::token_check');
- # get list of all gateways in table (not counting default gateway)
my $cache = {}; #cache for module info
- my $sth = $dbh->prepare('SELECT DISTINCT gatewaynum FROM payment_gateway')
- or die $dbh->errstr;
- $sth->execute or die $sth->errstr;
- my @gatewaynums;
- while (my $row = $sth->fetchrow_hashref) {
- push(@gatewaynums,$row->{'gatewaynum'});
- }
- $sth->finish;
# look for a gateway that can't tokenize
- my $disallow_untokenized = 1;
- foreach my $gatewaynum ('',@gatewaynums) {
- my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum, nofatal => 1 );
- if (!$gateway) { # already died if $gatewaynum
+ my $require_tokenized = 1;
+ foreach my $gateway (
+ FS::payment_gateway->all_gateways(
+ 'method' => 'CC',
+ 'conf' => $conf,
+ 'nofatal' => 1,
+ )
+ ) {
+ if (!$gateway) {
# no default gateway, no promise to tokenize
# can just load other gateways as-needeed below
- $disallow_untokenized = 0;
+ $require_tokenized = 0;
last;
}
my $info = _token_check_gateway_info($cache,$gateway);
- return $info unless ref($info); # means it's an error message
+ die $info unless ref($info); # means it's an error message
unless ($info->{'can_tokenize'}) {
# a configured gateway can't tokenize, that's all we need to know right now
# can just load other gateways as-needeed below
- $disallow_untokenized = 0;
+ $require_tokenized = 0;
last;
}
}
+ warn "REQUIRE TOKENIZED" if $require_tokenized && $debug;
+
+ # upgrade does not call this with autocommit turned on,
+ # and autocommit will be ignored if opt queue is set,
+ # but might as well be thorough...
my $oldAutoCommit = $FS::UID::AutoCommit;
local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ # for retrieving data in chunks
+ my $step = 500;
+ my $offset = 0;
### Tokenize cust_payby
- my $cust_search = FS::Cursor->new({ table => 'cust_main' },$dbh);
- while (my $cust_main = $cust_search->fetch) {
+ my @recnums;
+
+CUSTLOOP:
+ while (my $custnum = _token_check_next_recnum($dbh,'cust_main',$step,\$offset,\@recnums)) {
+ my $cust_main = FS::cust_main->by_key($custnum);
+ my $payment_gateway;
foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) {
- next if $cust_payby->tokenized;
- # load gateway first, just so we can cache it
- my $payment_gateway = $cust_main->_payment_gateway({
- 'nofatal' => 1, # handle error smoothly below
+
+ # see if it's already tokenized
+ if ($cust_payby->tokenized) {
+ warn "cust_payby ".$cust_payby->get($cust_payby->primary_key)." already tokenized" if $debug;
+ next;
+ }
+
+ if ($require_tokenized && $opt{'daily'}) {
+ $log->critical("Untokenized card number detected in cust_payby ".$cust_payby->custpaybynum);
+ $dbh->commit or die $dbh->errstr; # commit log message
+ }
+
+ # only load gateway if we need to, and only need to load it once
+ $payment_gateway ||= $cust_main->_payment_gateway({
+ 'method' => 'CC',
+ 'conf' => $conf,
+ 'nofatal' => 1, # handle lack of gateway smoothly below
});
unless ($payment_gateway) {
# no reason to have untokenized card numbers saved if no gateway,
- # but only fatal if we expected everyone to tokenize card numbers
- next unless $disallow_untokenized;
- $cust_search->DESTROY;
+ # but only a problem if we expected everyone to tokenize card numbers
+ unless ($require_tokenized) {
+ warn "Skipping cust_payby for cust_main ".$cust_main->custnum.", no payment gateway" if $debug;
+ next CUSTLOOP; # can skip rest of customer
+ }
+ my $error = "No gateway found for custnum ".$cust_main->custnum;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit error message
+ next; # not next CUSTLOOP, want to record error for every cust_payby
+ }
$dbh->rollback if $oldAutoCommit;
- return "No gateway found for custnum ".$cust_main->custnum;
+ die $error;
}
+
my $info = _token_check_gateway_info($cache,$payment_gateway);
+ unless (ref($info)) {
+ # only throws error if Business::OnlinePayment won't load,
+ # which is just cause to abort this whole process, even if queue
+ $dbh->rollback if $oldAutoCommit;
+ die $info; # error message
+ }
# no fail here--a configured gateway can't tokenize, so be it
- next unless ref($info) && $info->{'can_tokenize'};
+ unless ($info->{'can_tokenize'}) {
+ warn "Skipping ".$cust_main->custnum." cannot tokenize" if $debug;
+ next;
+ }
+
+ # time to tokenize
+ $cust_payby = $cust_payby->select_for_update;
my %tokenopts = (
'payment_gateway' => $payment_gateway,
'cust_payby' => $cust_payby,
@@ -2413,93 +2496,208 @@ sub token_check {
$error ||= 'Unknown error';
}
if ($error) {
- $cust_search->DESTROY;
+ $error = "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+ next; # not next CUSTLOOP, want to record error for every cust_payby
+ }
$dbh->rollback if $oldAutoCommit;
- return "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error;
+ die $error;
}
+ $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+ warn "TOKENIZED cust_payby ".$cust_payby->get($cust_payby->primary_key) if $debug;
}
+ warn "cust_payby upgraded for custnum ".$cust_main->custnum if $debug;
+
}
### Tokenize/mask transaction tables
+ # allow tokenization of closed cust_pay/cust_refund records
+ local $FS::payinfo_Mixin::allow_closed_replace = 1;
+
# grep assistance:
# $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here
foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) {
- my $search = FS::Cursor->new({
- table => $table,
- hashref => { 'payby' => 'CARD' },
- },$dbh);
- while (my $record = $search->fetch) {
- next if $record->tokenized;
- next if !$record->payinfo; #shouldn't happen, but at least it's not a card number
- next if $record->payinfo =~ /N\/A/; # ??? Not sure why we do this, but it's not a card number
-
- # don't use customer agent gateway here, use the gatewaynum specified by the record
- my $gatewaynum = $record->gatewaynum || '';
- my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum );
- unless ($gateway) { # already died if $gatewaynum
- # only fatal if we expected everyone to tokenize
- next unless $disallow_untokenized;
- $search->DESTROY;
- $dbh->rollback if $oldAutoCommit;
- return "No gateway found for $table ".$record->get($record->primary_key);
+ warn "Checking $table" if $debug;
+
+ # FS::Cursor does not seem to work over multiple commits (gives cursor not found errors)
+ # loading only record ids, then loading individual records one at a time
+ my $tclass = 'FS::'.$table;
+ $offset = 0;
+ @recnums = ();
+
+ while (my $recnum = _token_check_next_recnum($dbh,$table,$step,\$offset,\@recnums)) {
+ my $record = $tclass->by_key($recnum);
+ if (FS::cust_main::Billing_Realtime->tokenized($record->payinfo)) {
+ warn "Skipping tokenized record for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ if (!$record->payinfo) { #shouldn't happen, but at least it's not a card number
+ warn "Skipping blank payinfo for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ if ($record->payinfo =~ /N\/A/) { # ??? Not sure why we do this, but it's not a card number
+ warn "Skipping NA payinfo for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+
+ if ($require_tokenized && $opt{'daily'}) {
+ $log->critical("Untokenized card number detected in $table ".$record->get($record->primary_key));
+ $dbh->commit or die $dbh->errstr; # commit log message
+ }
+
+ my $cust_main = $record->cust_main;
+ if (!$cust_main) {
+ # might happen for cust_pay_pending from failed verify records,
+ # in which case we attempt tokenization without cust_main
+ # everything else should absolutely have a cust_main
+ if ($table eq 'cust_pay_pending' and !$record->custnum ) {
+ # override the usual safety check and allow the record to be
+ # updated even without a custnum.
+ $record->set('custnum_pending', 1);
+ } else {
+ my $error = "Could not load cust_main for $table ".$record->get($record->primary_key);
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message
+ next;
+ }
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
}
+
+ my $gateway;
+
+ # use the gatewaynum specified by the record if possible
+ $gateway = FS::payment_gateway->by_key_with_namespace(
+ 'gatewaynum' => $record->gatewaynum,
+ ) if $record->gateway;
+
+ # otherwise use the cust agent gateway if possible (which realtime_refund_bop would do)
+ # otherwise just use default gateway
+ unless ($gateway) {
+
+ $gateway = $cust_main
+ ? $cust_main->agent->payment_gateway
+ : FS::payment_gateway->default_gateway;
+
+ # check for processor mismatch
+ unless ($table eq 'cust_pay_pending') { # has no processor table
+ if (my $processor = $record->processor) {
+
+ my $conf_processor = $gateway->gateway_module;
+ my %bop_options = $gateway->gatewaynum
+ ? $gateway->options
+ : @{ $gateway->get('options') };
+
+ # this is the same standard used by realtime_refund_bop
+ unless (
+ ($processor eq $conf_processor) ||
+ (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}))
+ ) {
+
+ # processors don't match, so refund already cannot be run on this object,
+ # regardless of what we do now...
+ # but unless we gotta tokenize everything, just leave well enough alone
+ unless ($require_tokenized) {
+ warn "Skipping mismatched processor for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ ### no error--we'll tokenize using the new gateway, just to remove stored payinfo,
+ ### because refunds are already impossible for this record, anyway
+
+ } # end processor mismatch
+
+ } # end record has processor
+ } # end not cust_pay_pending
+
+ }
+
+ # means no default gateway, no promise to tokenize, can skip
+ unless ($gateway) {
+ warn "Skipping missing gateway for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+
my $info = _token_check_gateway_info($cache,$gateway);
unless (ref($info)) {
# only throws error if Business::OnlinePayment won't load,
- # which is just cause to abort this whole process
- $search->DESTROY;
+ # which is just cause to abort this whole process, even if queue
$dbh->rollback if $oldAutoCommit;
- return $info; # error message
+ die $info; # error message
}
# a configured gateway can't tokenize, move along
- next unless $info->{'can_tokenize'};
-
- my $cust_main = $record->cust_main;
- unless ($cust_main) {
- # might happen for cust_pay_pending for failed verify records,
- # in which case it *should* already be tokenized if possible
- # but only get strict about it if we're expecting full tokenization
- next if
- $table eq 'cust_pay_pending'
- && $record->{'custnum_pending'}
- && !$disallow_untokenized;
- # XXX we currently need a $cust_main to run realtime_tokenize
- # even if we made it a class method, wouldn't have access to payname/etc.
- # fail for now, but probably could handle this better...
- # everything else should absolutely have a cust_main
- $search->DESTROY;
- $dbh->rollback if $oldAutoCommit;
- return "Could not load cust_main for $table ".$record->get($record->primary_key);
+ unless ($info->{'can_tokenize'}) {
+ warn "Skipping, cannot tokenize $table ".$record->get($record->primary_key) if $debug;
+ next;
}
+
+ warn "ATTEMPTING GATEWAY-ONLY TOKENIZE" if $debug && !$cust_main;
+
+ # if we got this far, time to mutex
+ $record->select_for_update;
+
+ # no clear record of name/address/etc used for transaction,
+ # but will load name/phone/id from customer if run as an object method,
+ # so we try that if we can
my %tokenopts = (
'payment_gateway' => $gateway,
'method' => 'CC',
'payinfo' => $record->payinfo,
'paydate' => $record->paydate,
);
- my $error = $cust_main->realtime_tokenize(\%tokenopts);
- if ($cust_main->tokenized($tokenopts{'payinfo'})) { # implies no error
+ my $error = $cust_main
+ ? $cust_main->realtime_tokenize(\%tokenopts)
+ : FS::cust_main::Billing_Realtime->realtime_tokenize(\%tokenopts);
+ if (FS::cust_main::Billing_Realtime->tokenized($tokenopts{'payinfo'})) { # implies no error
$record->payinfo($tokenopts{'payinfo'});
$error = $record->replace;
} else {
- $error = 'Unknown error';
+ $error ||= 'Unknown error';
}
if ($error) {
- $search->DESTROY;
+ $error = "Error tokenizing $table ".$record->get($record->primary_key).": ".$error;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+ next;
+ }
$dbh->rollback if $oldAutoCommit;
- return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error;
+ die $error;
}
+ $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+ warn "TOKENIZED $table ".$record->get($record->primary_key) if $debug;
+
} # end record loop
} # end table loop
- $dbh->commit if $oldAutoCommit;
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
return '';
}
# not a method!
+sub _token_check_next_recnum {
+ my ($dbh,$table,$step,$offset,$recnums) = @_;
+ my $recnum = shift @$recnums;
+ return $recnum if $recnum;
+ my $tclass = 'FS::'.$table;
+ my $sth = $dbh->prepare('SELECT '.$tclass->primary_key.' FROM '.$table.' ORDER BY '.$tclass->primary_key.' LIMIT '.$step.' OFFSET '.$$offset) or die $dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ my @recnums;
+ while (my $rec = $sth->fetchrow_hashref) {
+ push @$recnums, $rec->{$tclass->primary_key};
+ }
+ $sth->finish();
+ $$offset += $step;
+ return shift @$recnums;
+}
+
+# not a method!
sub _token_check_gateway_info {
my ($cache,$payment_gateway) = @_;
@@ -2537,8 +2735,6 @@ sub _token_check_gateway_info {
$info->{'void_requires_card'} = 1
if $transaction->info('CC_void_requires_card');
- $cache->{$payment_gateway->gateway_module} = $info;
-
return $info;
}
diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm
index 51aa79de5..387883b63 100644
--- a/FS/FS/log_context.pm
+++ b/FS/FS/log_context.pm
@@ -11,6 +11,7 @@ my @contexts = ( qw(
FS::cust_main::Billing_Realtime::realtime_bop
FS::cust_main::Billing_Realtime::realtime_tokenize
FS::cust_main::Billing_Realtime::realtime_verify_bop
+ FS::cust_main::Billing_Realtime::token_check
FS::pay_batch::import_from_gateway
FS::part_pkg
FS::Misc::Geo::standardize_uscensus
@@ -28,6 +29,7 @@ my @contexts = ( qw(
freeside-paymentech-upload
freeside-paymentech-download
test
+ FS::TaxEngine::billsoft
) );
=head1 NAME
diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm
index 2f503129d..3a51022d3 100644
--- a/FS/FS/payinfo_Mixin.pm
+++ b/FS/FS/payinfo_Mixin.pm
@@ -195,8 +195,6 @@ sub payinfo_check {
FS::payby->can_payby($self->table, $self->payby)
or return "Illegal payby: ". $self->payby;
- my $conf = new FS::Conf;
-
if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
my $payinfo = $self->payinfo;
@@ -468,6 +466,7 @@ Optionally, an arbitrary payby and payinfo can be passed.
sub tokenized {
my $self = shift;
my $payinfo = scalar(@_) ? shift : $self->payinfo;
+ return 0 unless $payinfo; #avoid uninitialized value error
$payinfo =~ /^99\d{14}$/;
}
diff --git a/FS/FS/payinfo_transaction_Mixin.pm b/FS/FS/payinfo_transaction_Mixin.pm
index c27d0494b..1b5a0cdff 100644
--- a/FS/FS/payinfo_transaction_Mixin.pm
+++ b/FS/FS/payinfo_transaction_Mixin.pm
@@ -102,8 +102,6 @@ auth, and order_number) as well as payby and payinfo
sub payinfo_check {
my $self = shift;
- my $conf = new FS::Conf;
-
$self->SUPER::payinfo_check()
|| $self->ut_numbern('gatewaynum')
# not ut_foreign_keyn, it causes upgrades to fail
diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm
index afae2667e..3500bf9bc 100644
--- a/FS/FS/payment_gateway.pm
+++ b/FS/FS/payment_gateway.pm
@@ -323,6 +323,119 @@ sub processor {
}
}
+=item default_gateway OPTIONS
+
+Class method.
+
+Returns default gateway (from business-onlinepayment conf) as a payment_gateway object.
+
+Accepts options
+
+conf - existing conf object
+
+nofatal - return blank instead of dying if no default gateway is configured
+
+method - if set to CHEK or ECHECK, returns object for business-onlinepayment-ach if available
+
+Before using this, be sure you wouldn't rather be using L</by_key_or_default> or,
+more likely, L<FS::agent/payment_gateway>.
+
+=cut
+
+# the standard settings from the config could be moved to a null agent
+# agent_payment_gateway referenced payment_gateway
+
+sub default_gateway {
+ my ($self,%options) = @_;
+
+ $options{'conf'} ||= new FS::Conf;
+ my $conf = $options{'conf'};
+
+ unless ( $conf->exists('business-onlinepayment') ) {
+ if ( $options{'nofatal'} ) {
+ return '';
+ } else {
+ die "Real-time processing not enabled\n";
+ }
+ }
+
+ #load up config
+ my $bop_config = 'business-onlinepayment';
+ $bop_config .= '-ach'
+ if ( $options{method}
+ && $options{method} =~ /^(ECHECK|CHEK)$/
+ && $conf->exists($bop_config. '-ach')
+ );
+ my ( $processor, $login, $password, $action, @bop_options ) =
+ $conf->config($bop_config);
+ $action ||= 'normal authorization';
+ pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+ die "No real-time processor is enabled - ".
+ "did you set the business-onlinepayment configuration value?\n"
+ unless $processor;
+
+ my $payment_gateway = new FS::payment_gateway;
+ $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
+ 'Business::OnlinePayment');
+ $payment_gateway->gateway_module($processor);
+ $payment_gateway->gateway_username($login);
+ $payment_gateway->gateway_password($password);
+ $payment_gateway->gateway_action($action);
+ $payment_gateway->set('options', [ @bop_options ]);
+ return $payment_gateway;
+}
+
+=item by_key_with_namespace GATEWAYNUM
+
+Like usual by_key, but makes sure namespace is set,
+and dies if not found.
+
+=cut
+
+sub by_key_with_namespace {
+ my $self = shift;
+ my $payment_gateway = $self->by_key(@_);
+ die "payment_gateway not found"
+ unless $payment_gateway;
+ $payment_gateway->gateway_namespace('Business::OnlinePayment')
+ unless $payment_gateway->gateway_namespace;
+ return $payment_gateway;
+}
+
+=item by_key_or_default OPTIONS
+
+Either returns the gateway specified by option gatewaynum, or the default gateway.
+
+Accepts the same options as L</default_gateway>.
+
+Also ensures that the gateway_namespace has been set.
+
+=cut
+
+sub by_key_or_default {
+ my ($self,%options) = @_;
+
+ if ($options{'gatewaynum'}) {
+ return $self->by_key_with_namespace($options{'gatewaynum'});
+ } else {
+ return $self->default_gateway(%options);
+ }
+}
+
+# if it weren't for the way gateway_namespace default is set, this method would not be necessary
+# that should really go in check() with an accompanying upgrade, so we could just use qsearch safely,
+# but currently short on time to test deeper changes...
+#
+# if no default gateway is set and nofatal is passed, first value returned is blank string
+sub all_gateways {
+ my ($self,%options) = @_;
+ my @out;
+ foreach my $gatewaynum ('',( map {$_->gatewaynum} qsearch('payment_gateway') )) {
+ push @out, $self->by_key_or_default( %options, gatewaynum => $gatewaynum );
+ }
+ return @out;
+}
+
# _upgrade_data
#
# Used by FS::Upgrade to migrate to a new database.
diff --git a/FS/FS/tax_status.pm b/FS/FS/tax_status.pm
index 5f7b50fde..2dbcdfa13 100644
--- a/FS/FS/tax_status.pm
+++ b/FS/FS/tax_status.pm
@@ -155,6 +155,12 @@ sub _upgrade_data {
'I' => 'Industrial',
'L' => 'Lifeline',
},
+ billsoft => {
+ 'Residential' => 'Residential',
+ 'Business' => 'Business',
+ 'Industrial' => 'Industrial',
+ 'Senior Citizen' => 'Senior Citizen',
+ },
);
=back
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index ee95c14db..e1463f5da 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -97,7 +97,7 @@ use FS::Cron::backup qw(backup);
backup();
#except we'd rather not start cleanup jobs until the backup is done
-cleanup();
+cleanup( quiet => !$opt{'v'} );
$log->info('finish');
diff --git a/FS/t/suite/13-tokenization.t b/FS/t/suite/13-tokenization.t
new file mode 100755
index 000000000..0a965aa87
--- /dev/null
+++ b/FS/t/suite/13-tokenization.t
@@ -0,0 +1,179 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Test;
+use Test::More;
+use FS::Conf;
+use FS::cust_main;
+use Business::CreditCard qw(generate_last_digit);
+use DateTime;
+if ( stat('/usr/local/etc/freeside/cardfortresstest.txt') ) {
+ plan tests => 18;
+} else {
+ plan skip_all => 'CardFortress test encryption key is not installed.';
+}
+
+### can only run on test database (company name "Freeside Test")
+### will run upgrade, which uses lots of prints & warns beyond regular test output
+
+my $fs = FS::Test->new( user => 'admin' );
+my $conf = FS::Conf->new;
+my $err;
+my $bopconf;
+
+like( $conf->config('company_name'), qr/^Freeside Test/, 'using test database' ) or BAIL_OUT('');
+
+# test db no longer contains cardtype overrides
+
+$bopconf =
+'IPPay
+TESTTERMINAL';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting first default gateway" ) or BAIL_OUT('');
+
+# generate a few void/refund records for upgrading
+my $counter = 20;
+foreach my $cust_pay ( $fs->qsearch('cust_pay',{ payby => 'CARD' }) ) {
+ if ($counter % 2) {
+ $err = $cust_pay->void('Testing');
+ $err = "Voiding: $err" if $err;
+ } else {
+ # from realtime_refund_bop, just the important bits
+ while ( $cust_pay->unapplied < $cust_pay->paid ) {
+ my @cust_bill_pay = $cust_pay->cust_bill_pay;
+ last unless @cust_bill_pay;
+ my $cust_bill_pay = pop @cust_bill_pay;
+ $err = $cust_bill_pay->delete;
+ $err = "Refund unapply: $err" if $err;
+ last if $err;
+ }
+ last if $err;
+ my $cust_refund = new FS::cust_refund ( {
+ 'custnum' => $cust_pay->cust_main->custnum,
+ 'paynum' => $cust_pay->paynum,
+ 'source_paynum' => $cust_pay->paynum,
+ 'refund' => $cust_pay->paid,
+ '_date' => '',
+ 'payby' => $cust_pay->payby,
+ 'payinfo' => $cust_pay->payinfo,
+ 'reason' => 'Testing',
+ 'gatewaynum' => $cust_pay->gatewaynum,
+ 'processor' => $cust_pay->payment_gateway ? $cust_pay->payment_gateway->processor : '',
+ 'auth' => $cust_pay->auth,
+ 'order_number' => $cust_pay->order_number,
+ } );
+ $err = $cust_refund->insert( reason_type => 'Refund' );
+ $err = "Refunding: $err" if $err;
+ }
+ last if $err;
+ $counter -= 1;
+ last unless $counter > 0;
+}
+ok( !$err, "create some refunds and voids" ) or BAIL_OUT($err);
+
+# also, just to test behavior in this case, create a record for an aborted
+# verification payment. this will have no customer number.
+
+my $pending_failed = FS::cust_pay_pending->new({
+ 'custnum_pending' => 1,
+ 'paid' => '1.00',
+ '_date' => time - 86400,
+ random_card(),
+ 'status' => 'failed',
+ 'statustext' => 'Tokenization upgrade test',
+});
+$err = $pending_failed->insert;
+ok( !$err, "create a failed payment attempt" ) or BAIL_OUT($err);
+
+# find two stored credit cards.
+my @cust = map { FS::cust_main->by_key($_) } (10, 12);
+my @payby = map { ($_->cust_payby)[0] } @cust;
+my @payment;
+
+ok( $payby[0]->payby eq 'CARD' && !$payby[0]->tokenized,
+ "first customer has a non-tokenized card"
+ ) or BAIL_OUT();
+
+$err = $cust[0]->realtime_cust_payby(amount => '2.00');
+ok( !$err, "create a payment through IPPay" )
+ or BAIL_OUT($err);
+$payment[0] = $fs->qsearchs('cust_pay', { custnum => $cust[0]->custnum,
+ paid => '2.00' })
+ or BAIL_OUT("can't find payment record");
+
+$err = system('freeside-upgrade','admin');
+ok( !$err, 'initial upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+# switch to CardFortress
+$bopconf =
+'CardFortress
+cardfortresstest
+(TEST54)
+Normal Authorization
+gateway
+IPPay
+gateway_login
+TESTTERMINAL
+gateway_password
+
+private_key
+/usr/local/etc/freeside/cardfortresstest.txt';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting tokenizable default gateway" ) or BAIL_OUT('');
+
+# create a payment using a non-tokenized card. this should immediately
+# trigger tokenization.
+ok( $payby[1]->payby eq 'CARD' && ! $payby[1]->tokenized,
+ "second customer has a non-tokenized card"
+ ) or BAIL_OUT();
+
+$err = $cust[1]->realtime_cust_payby(amount => '3.00');
+ok( !$err, "tokenize a card when it's first used for payment" )
+ or BAIL_OUT($err);
+$payment[1] = $fs->qsearchs('cust_pay', { custnum => $cust[1]->custnum,
+ paid => '3.00' })
+ or BAIL_OUT("can't find payment record");
+ok( $payment[1]->tokenized, "payment is tokenized" );
+$payby[1] = $payby[1]->replace_old;
+ok( $payby[1]->tokenized, "card is now tokenized" );
+
+# invoke the part of freeside-upgrade that tokenizes
+FS::cust_main->queueable_upgrade();
+#$err = system('freeside-upgrade','admin');
+#ok( !$err, 'tokenizable upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+$payby[0] = $payby[0]->replace_old;
+ok( $payby[0]->tokenized, "old card was tokenized during upgrade" );
+$payment[0] = $payment[0]->replace_old;
+ok( $payment[0]->tokenized, "old payment was tokenized during upgrade" );
+ok( ($payment[0]->cust_pay_pending)[0]->tokenized, "old cust_pay_pending was tokenized during upgrade" );
+
+$pending_failed = $pending_failed->replace_old;
+ok( $pending_failed->tokenized, "cust_pay_pending with no customer was tokenized" );
+
+# add a new payment card to one customer
+$payby[2] = FS::cust_payby->new({
+ custnum => $cust[0]->custnum,
+ random_card(),
+});
+$err = $payby[2]->insert;
+ok( !$err, "new card was saved" );
+ok($payby[2]->tokenized, "new card is tokenized" );
+
+sub random_card {
+ my $payinfo = '4111' . join('', map { int(rand(10)) } 1 .. 11);
+ $payinfo .= generate_last_digit($payinfo);
+ my $paydate = DateTime->now
+ ->add('years' => 1)
+ ->truncate(to => 'month')
+ ->strftime('%F');
+ return ( 'payby' => 'CARD',
+ 'payinfo' => $payinfo,
+ 'paydate' => $paydate,
+ 'payname' => 'Tokenize Me',
+ );
+}
+
+
+1;
+