diff options
Diffstat (limited to 'FS')
-rw-r--r-- | FS/FS/Conf.pm | 9 | ||||
-rw-r--r-- | FS/FS/Cron/cleanup.pm | 16 | ||||
-rwxr-xr-x | FS/FS/Cron/tax_rate_update.pm | 2 | ||||
-rw-r--r-- | FS/FS/Daemon.pm | 12 | ||||
-rw-r--r-- | FS/FS/TaxEngine/billsoft.pm | 635 | ||||
-rw-r--r-- | FS/FS/TicketSystem.pm | 19 | ||||
-rw-r--r-- | FS/FS/Upgrade.pm | 12 | ||||
-rw-r--r-- | FS/FS/agent.pm | 83 | ||||
-rw-r--r-- | FS/FS/agent_payment_gateway.pm | 1 | ||||
-rw-r--r-- | FS/FS/cust_main.pm | 5 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing_Realtime.pm | 432 | ||||
-rw-r--r-- | FS/FS/log_context.pm | 2 | ||||
-rw-r--r-- | FS/FS/payinfo_Mixin.pm | 3 | ||||
-rw-r--r-- | FS/FS/payinfo_transaction_Mixin.pm | 2 | ||||
-rw-r--r-- | FS/FS/payment_gateway.pm | 113 | ||||
-rw-r--r-- | FS/FS/tax_status.pm | 6 | ||||
-rwxr-xr-x | FS/bin/freeside-daily | 2 | ||||
-rwxr-xr-x | FS/t/suite/13-tokenization.t | 179 |
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; + |