diff options
Diffstat (limited to 'FS')
| -rw-r--r-- | FS/FS/Report/Table.pm | 37 | ||||
| -rw-r--r-- | FS/FS/Report/Table/Monthly.pm | 7 | ||||
| -rw-r--r-- | FS/FS/Schema.pm | 18 | ||||
| -rw-r--r-- | FS/FS/TicketSystem.pm | 67 | ||||
| -rw-r--r-- | FS/FS/TicketSystem/RT_Internal.pm | 2 | ||||
| -rw-r--r-- | FS/FS/cust_bill.pm | 9 | ||||
| -rw-r--r-- | FS/FS/cust_bill_pkg.pm | 48 | ||||
| -rw-r--r-- | FS/FS/cust_main.pm | 5 | ||||
| -rw-r--r-- | FS/FS/cust_main/Billing.pm | 443 | ||||
| -rw-r--r-- | FS/FS/cust_main/Billing_Realtime.pm | 69 | ||||
| -rw-r--r-- | FS/FS/cust_main_county.pm | 88 | ||||
| -rw-r--r-- | FS/FS/cust_pay.pm | 54 | ||||
| -rw-r--r-- | FS/FS/cust_pay_void.pm | 2 | ||||
| -rw-r--r-- | FS/FS/cust_refund.pm | 5 | ||||
| -rw-r--r-- | FS/FS/payinfo_Mixin.pm | 28 | ||||
| -rw-r--r-- | FS/FS/payinfo_transaction_Mixin.pm | 56 | ||||
| -rw-r--r-- | FS/bin/freeside-ipifony-download | 133 | ||||
| -rwxr-xr-x | FS/bin/freeside-void-payments | 13 | 
18 files changed, 705 insertions, 379 deletions
| diff --git a/FS/FS/Report/Table.pm b/FS/FS/Report/Table.pm index 696940679..2e202e5d9 100644 --- a/FS/FS/Report/Table.pm +++ b/FS/FS/Report/Table.pm @@ -56,6 +56,13 @@ sub signups {      push @where, "refnum = ".$opt{'refnum'};    } +  if ( $opt{'cust_classnum'} ) { +    my $classnums = $opt{'cust_classnum'}; +    $classnums = [ $classnums ] if !ref($classnums); +    @$classnums = grep /^\d+$/, @$classnums; +    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')'; +  } +    $self->scalar_sql(      "SELECT COUNT(*) FROM cust_main $join WHERE ".join(' AND ', @where)    ); @@ -439,8 +446,16 @@ sub cust_bill_pkg_setup {      $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),    ); +  # yuck, false laziness    push @where, "cust_main.refnum = ". $opt{'refnum'} if $opt{'refnum'}; +  if ( $opt{'cust_classnum'} ) { +    my $classnums = $opt{'cust_classnum'}; +    $classnums = [ $classnums ] if !ref($classnums); +    @$classnums = grep /^\d+$/, @$classnums; +    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')'; +  } +    my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)    FROM cust_bill_pkg    $cust_bill_pkg_join @@ -463,6 +478,13 @@ sub cust_bill_pkg_recur {    push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; +  if ( $opt{'cust_classnum'} ) { +    my $classnums = $opt{'cust_classnum'}; +    $classnums = [ $classnums ] if !ref($classnums); +    @$classnums = grep /^\d+$/, @$classnums; +    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')'; +  } +    # subtract all usage from the line item regardless of date    my $item_usage;    if ( $opt{'project'} ) { @@ -518,6 +540,13 @@ sub cust_bill_pkg_detail {    push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; +  if ( $opt{'cust_classnum'} ) { +    my $classnums = $opt{'cust_classnum'}; +    $classnums = [ $classnums ] if !ref($classnums); +    @$classnums = grep /^\d+$/, @$classnums; +    push @where, 'cust_main.classnum in('. join(',',@$classnums) .')'; +  } +    $agentnum ||= $opt{'agentnum'};    push @where, @@ -657,6 +686,14 @@ sub for_opts {      if ( $opt{'refnum'} =~ /^(\d+)$/ ) {        $sql .= " and refnum = $1 ";      } +    if ( $opt{'cust_classnum'} ) { +      my $classnums = $opt{'cust_classnum'}; +      $classnums = [ $classnums ] if !ref($classnums); +      @$classnums = grep /^\d+$/, @$classnums; +      $sql .= ' and cust_main.classnum in('. join(',',@$classnums) .')' +        if @$classnums; +    } +      $sql;  } diff --git a/FS/FS/Report/Table/Monthly.pm b/FS/FS/Report/Table/Monthly.pm index ee4dc5fe8..b8e52ae63 100644 --- a/FS/FS/Report/Table/Monthly.pm +++ b/FS/FS/Report/Table/Monthly.pm @@ -25,6 +25,7 @@ FS::Report::Table::Monthly - Tables of report data, indexed monthly      #opt      'agentnum'    => 54      'refnum'      => 54 +    'cust_classnum' => [ 1,2,4 ],      'params'      => [ [ 'paramsfor', 'item_one' ], [ 'item', 'two' ] ], # ...      'remove_empty' => 1, #collapse empty rows, default 0      'item_labels' => [ ], #useful with remove_empty @@ -69,6 +70,9 @@ corresponding to this arrayref.  =item refnum: Limit to customers with this advertising source. +=item cust_classnum: Limit to customers with this classnum; can be an  +arrayref. +  =item remove_empty: Set this to a true value to hide rows that contain   only zeroes.  The C<indices> array in the returned data will list the item  indices that are actually present in the output so that you know what they @@ -139,6 +143,8 @@ sub data {    my $agentnum = $self->{'agentnum'};    my $refnum = $self->{'refnum'}; +  my $cust_classnum = $self->{'cust_classnum'} || []; +  $cust_classnum = [ $cust_classnum ] if !ref($cust_classnum);    if ( $projecting ) { @@ -183,6 +189,7 @@ sub data {        my @param = $self->{'params'} ? @{ $self->{'params'}[$col] }: ();        push @param, 'project', $projecting;        push @param, 'refnum' => $refnum if $refnum; +      push @param, 'cust_classnum' => $cust_classnum if @$cust_classnum;        if ( $self->{'cross_params'} ) {          my @xdata; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index b6fd3b67b..cbcd27b46 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1560,7 +1560,14 @@ sub tables_hashref {          'depositor',  'varchar', 'NULL', $char_d, '', '',          'account',    'varchar', 'NULL', 20,      '', '',          'teller',     'varchar', 'NULL', 20,      '', '', +          'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key + +        # credit card/EFT fields (formerly in paybatch) +        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK +        'processor',  'varchar', 'NULL', $char_d, '', '', # module name +        'auth',       'varchar','NULL',16, '', '', # CC auth number +        'order_number','varchar','NULL',$char_d, '', '', # transaction number        ],        'primary_key' => 'paynum',        #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ], @@ -1591,6 +1598,12 @@ sub tables_hashref {          'teller',     'varchar', 'NULL', 20,      '', '',          'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key +        # credit card/EFT fields (formerly in paybatch) +        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK +        'processor',  'varchar', 'NULL', $char_d, '', '', # module name +        'auth',       'varchar','NULL',16, '', '', # CC auth number +        'order_number', 'varchar','NULL',$char_d, '', '', # transaction number +          #void fields          'void_date', @date_type, '', '',           'reason',    'varchar',   'NULL', $char_d, '', '',  @@ -1858,6 +1871,11 @@ sub tables_hashref {  	'paymask', 'varchar', 'NULL', $char_d, '', '',           'paybatch',     'varchar',   'NULL', $char_d, '', '',           'closed',    'char', 'NULL', 1, '', '',  +        # credit card/EFT fields (formerly in paybatch) +        'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK +        'processor',  'varchar', 'NULL', $char_d, '', '', # module name +        'auth',       'varchar','NULL',16, '', '', # CC auth number +        'order_number', 'varchar','NULL',$char_d, '', '', # transaction number        ],        'primary_key' => 'refundnum',        'unique' => [], diff --git a/FS/FS/TicketSystem.pm b/FS/FS/TicketSystem.pm index c1553f17a..165856e5f 100644 --- a/FS/FS/TicketSystem.pm +++ b/FS/FS/TicketSystem.pm @@ -87,6 +87,8 @@ sub _upgrade_data {    # bypass RT ACLs--we're going to do lots of things    my $CurrentUser = $RT::SystemUser; +  my $dbh = dbh; +    # selfservice and cron users    foreach my $username ('%%%SELFSERVICE_USER%%%', 'fs_daily') {      my $User = RT::User->new($CurrentUser); @@ -252,6 +254,71 @@ sub _upgrade_data {      die $msg if !$val;    } #foreach (@Scrips) +  # one-time fix: accumulator fields (support time, etc.) that had values  +  # entered on ticket creation need OCFV records attached to their Create +  # transactions +  my $sql = 'SELECT first_ocfv.ObjectId, first_ocfv.Created, Content '. +    'FROM ObjectCustomFieldValues as first_ocfv '. +    'JOIN ('. +      # subquery to get the first OCFV with a certain name for each ticket +      'SELECT min(ObjectCustomFieldValues.Id) AS Id '. +      'FROM ObjectCustomFieldValues '. +      'JOIN CustomFields '. +      'ON (ObjectCustomFieldValues.CustomField = CustomFields.Id) '. +      'WHERE ObjectType = \'RT::Ticket\' '. +      'AND CustomFields.Name = ? '. +      'GROUP BY ObjectId'. +    ') AS first_ocfv_id USING (Id) '. +    'JOIN ('. +      # subquery to get the first transaction date for each ticket +      # other than the Create +      'SELECT ObjectId, min(Created) AS Created FROM Transactions '. +      'WHERE ObjectType = \'RT::Ticket\' '. +      'AND Type != \'Create\' '. +      'GROUP BY ObjectId'. +    ') AS first_txn ON (first_ocfv.ObjectId = first_txn.ObjectId) '. +    # where the ticket custom field acquired a value before any transactions +    # on the ticket (i.e. it was set on ticket creation) +    'WHERE first_ocfv.Created < first_txn.Created '. +    # and we haven't already fixed the ticket +    'AND NOT EXISTS('. +      'SELECT 1 FROM Transactions JOIN ObjectCustomFieldValues '. +      'ON (Transactions.Id = ObjectCustomFieldValues.ObjectId) '. +      'JOIN CustomFields '. +      'ON (ObjectCustomFieldValues.CustomField = CustomFields.Id) '. +      'WHERE ObjectCustomFieldValues.ObjectType = \'RT::Transaction\' '. +      'AND CustomFields.Name = ? '. +      'AND Transactions.Type = \'Create\''. +      'AND Transactions.ObjectType = \'RT::Ticket\''. +      'AND Transactions.ObjectId = first_ocfv.ObjectId'. +    ')'; +    #whew + +  # prior to this fix, the only name an accumulate field could possibly have  +  # was "Support time". +  my $sth = $dbh->prepare($sql); +  $sth->execute('Support time', 'Support time'); +  my $rows = $sth->rows; +  warn "Fixing support time on $rows rows...\n" if $rows > 0; +  while ( my $row = $sth->fetchrow_arrayref ) { +    my ($tid, $created, $content) = @$row; +    my $Txns = RT::Transactions->new($CurrentUser); +    $Txns->Limit(FIELD => 'ObjectId', VALUE => $tid); +    $Txns->Limit(FIELD => 'ObjectType', VALUE => 'RT::Ticket'); +    $Txns->Limit(FIELD => 'Type', VALUE => 'Create'); +    my $CreateTxn = $Txns->First; +    if ($CreateTxn) { +      my ($val, $msg) = $CreateTxn->AddCustomFieldValue( +        Field => 'Support time', +        Value => $content, +        RecordTransaction => 0, +      ); +      warn "Error setting transaction support time: $msg\n" unless $val; +    } else { +      warn "Create transaction not found for ticket $tid.\n"; +    } +  } +    return;  } diff --git a/FS/FS/TicketSystem/RT_Internal.pm b/FS/FS/TicketSystem/RT_Internal.pm index 01e2e2966..665c16692 100644 --- a/FS/FS/TicketSystem/RT_Internal.pm +++ b/FS/FS/TicketSystem/RT_Internal.pm @@ -589,7 +589,7 @@ sub _web_external_auth {           # we failed to successfully create the user. abort abort abort.            delete $session->{'CurrentUser'}; -          die "can't auto-create RT user"; #an error message would be nice :/ +          die "can't auto-create RT user: $msg"; #an error message would be nice :/            #$m->abort() unless $RT::WebFallbackToInternalAuth;            #$m->comp( '/Elements/Login', %ARGS,            #    Error => loc( 'Cannot create user: [_1]', $msg ) ); diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index e4b2df4e8..e7622d712 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -3429,6 +3429,15 @@ sub search_sql_where {      push @search, "cust_bill.custnum = $1";    } +  #customer classnum +  if ( $param->{'cust_classnum'} ) { +    my $classnums = $param->{'cust_classnum'}; +    $classnums = [ $classnums ] if !ref($classnums); +    $classnums = [ grep /^\d+$/, @$classnums ]; +    push @search, 'cust_main.classnum in ('.join(',',@$classnums).')' +      if @$classnums; +  } +    #_date    if ( $param->{_date} ) {      my($beginning, $ending) = @{$param->{_date}}; diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index 5cff140bb..716c0983e 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -201,16 +201,50 @@ sub insert {    my $tax_location = $self->get('cust_bill_pkg_tax_location');    if ( $tax_location ) { -    foreach my $cust_bill_pkg_tax_location ( @$tax_location ) { -      $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum); -      $error = $cust_bill_pkg_tax_location->insert; -      if ( $error ) { -        $dbh->rollback if $oldAutoCommit; -        return "error inserting cust_bill_pkg_tax_location: $error"; +    foreach my $link ( @$tax_location ) { +      next if $link->billpkgtaxlocationnum; # don't try to double-insert +      # This cust_bill_pkg can be linked on either side (i.e. it can be the +      # tax or the taxed item).  If the other side is already inserted,  +      # then set billpkgnum to ours, and insert the link.  Otherwise, +      # set billpkgnum to ours and pass the link off to the cust_bill_pkg +      # on the other side, to be inserted later. + +      my $tax_cust_bill_pkg = $link->get('tax_cust_bill_pkg'); +      if ( $tax_cust_bill_pkg && $tax_cust_bill_pkg->billpkgnum ) { +        $link->set('billpkgnum', $tax_cust_bill_pkg->billpkgnum); +        # break circular links when doing this +        $link->set('tax_cust_bill_pkg', '');        } -    } +      my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); +      if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) { +        $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum); +        # XXX if we ever do tax-on-tax for these, this will have to change +        # since pkgnum will be zero +        $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum); +        $link->set('locationnum',  +          $taxable_cust_bill_pkg->cust_pkg->tax_locationnum); +        $link->set('taxable_cust_bill_pkg', ''); +      } + +      if ( $link->billpkgnum and $link->taxable_billpkgnum ) { +        $error = $link->insert; +        if ( $error ) { +          $dbh->rollback if $oldAutoCommit; +          return "error inserting cust_bill_pkg_tax_location: $error"; +        } +      } else { # handoff +        my $other; +        $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg') +                                   : $link->get('tax_cust_bill_pkg'); +        my $link_array = $other->get('cust_bill_pkg_tax_location') || []; +        push @$link_array, $link; +        $other->set('cust_bill_pkg_tax_location' => $link_array); +      } +    } #foreach my $link    } +  # someday you will be as awesome as cust_bill_pkg_tax_location... +  # but not today    my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');    if ( $tax_rate_location ) {      foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) { diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 4ea4a6b9d..45d57cd79 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -3403,6 +3403,8 @@ New-style, with a hashref of options:                                      'setuptax'   => '', # or 'Y' for tax exempt +                                    'locationnum'=> 1234, # optional +                                      #internal taxation                                      'taxclass'   => 'Tax class', @@ -3434,6 +3436,7 @@ sub charge {    my $no_auto = '';    my $cust_pkg_ref = '';    my ( $bill_now, $invoice_terms ) = ( 0, '' ); +  my $locationnum;    if ( ref( $_[0] ) ) {      $amount     = $_[0]->{amount};      $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; @@ -3451,6 +3454,7 @@ sub charge {      $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';      $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';      $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : ''; +    $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;    } else {      $amount     = shift;      $quantity   = 1; @@ -3517,6 +3521,7 @@ sub charge {      'quantity'   => $quantity,      'start_date' => $start_date,      'no_auto'    => $no_auto, +    'locationnum'=> $locationnum,    } );    $error = $cust_pkg->insert; diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 6d3ff9146..cd46c7332 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -687,8 +687,6 @@ sub _omit_zero_value_bundles {  =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME -This is a weird one.  Perhaps it should not even be exposed. -  Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.  Usually used internally by bill method B<bill>. @@ -755,7 +753,7 @@ sub calculate_taxes {    # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs    my %tax_rate_location = (); -  # keys are taxnums (not internal identifiers!) +  # keys are taxlisthash keys (internal identifiers!)    # values are arrayrefs of cust_tax_exempt_pkg objects    my %tax_exemption; @@ -775,45 +773,35 @@ sub calculate_taxes {      # It also calculates exemptions and attaches them to the cust_bill_pkgs      # in the argument.      my $taxables = $taxlisthash->{$tax}; -    my $exemptions = $tax_exemption{$tax_object->taxnum} ||= []; -    my $hashref_or_error = -      $tax_object->taxline( $taxables, +    my $exemptions = $tax_exemption{$tax} ||= []; +    my $taxline = $tax_object->taxline( +                            $taxables,                              'custnum'      => $self->custnum,                              'invoice_time' => $invoice_time,                              'exemptions'   => $exemptions,                            ); -    return $hashref_or_error unless ref($hashref_or_error); - -    # then collect any new exemptions generated for this tax -    push @$exemptions, @{ $_->cust_tax_exempt_pkg } -      foreach @$taxables; +    return $taxline unless ref($taxline);      unshift @{ $taxlisthash->{$tax} }, $tax_object; -    my $name   = $hashref_or_error->{'name'}; -    my $amount = $hashref_or_error->{'amount'}; +    if ( $tax_object->isa('FS::cust_main_county') ) { +      # then $taxline is a real line item +      push @{ $taxname{ $taxline->itemdesc } }, $taxline; -    #warn "adding $amount as $name\n"; -    $taxname{ $name } ||= []; -    push @{ $taxname{ $name } }, $tax; +    } else { +      # leave this as is for now -    $tax_amount{ $tax } += $amount; +      my $name   = $taxline->{'name'}; +      my $amount = $taxline->{'amount'}; -    # link records between cust_main_county/tax_rate and cust_location -    $tax_location{ $tax } ||= []; -    $tax_rate_location{ $tax } ||= []; -    if ( ref($tax_object) eq 'FS::cust_main_county' ) { -      push @{ $tax_location{ $tax }  }, -        { -          'taxnum'      => $tax_object->taxnum,  -          'taxtype'     => ref($tax_object), -          'pkgnum'      => $tax_object->get('pkgnum'), -          'locationnum' => $tax_object->get('locationnum'), -          'amount'      => sprintf('%.2f', $amount ), -          'taxable_billpkgnum' => $tax_object->get('billpkgnum'), -        }; -    } -    elsif ( ref($tax_object) eq 'FS::tax_rate' ) { +      #warn "adding $amount as $name\n"; +      $taxname{ $name } ||= []; +      push @{ $taxname{ $name } }, $tax; + +      $tax_amount{ $tax } += $amount; + +      # link records between cust_main_county/tax_rate and cust_location +      $tax_rate_location{ $tax } ||= [];        my $taxratelocationnum =          $tax_object->tax_rate_location->taxratelocationnum;        push @{ $tax_rate_location{ $tax }  }, @@ -823,56 +811,53 @@ sub calculate_taxes {            'amount'             => sprintf('%.2f', $amount ),            'locationtaxid'      => $tax_object->location,            'taxratelocationnum' => $taxratelocationnum, -          'taxable_billpkgnum' => $tax_object->get('billpkgnum'),          }; -    } - -  } - -  #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit -  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg; -  foreach my $tax ( keys %$taxlisthash ) { -    my $taxables = $taxlisthash->{$tax}; -    my $tax_object = shift @$taxables; # the rest are line items -    foreach my $cust_bill_pkg ( @$taxables ) { -      next unless ref($cust_bill_pkg) eq 'FS::cust_bill_pkg'; #IS needed for CCH tax-on-tax - -      my @cust_tax_exempt_pkg = splice @{ $cust_bill_pkg->cust_tax_exempt_pkg }; - -      next unless @cust_tax_exempt_pkg; -      # get the non-disintegrated version -      my $real_cust_bill_pkg = $packagemap{$cust_bill_pkg->pkgnum} -        or die "can't distribute tax exemptions: no line item for ". -          Dumper($_). " in packagemap ".  -          join(',', sort {$a<=>$b} keys %packagemap). "\n"; - -      push @{ $real_cust_bill_pkg->cust_tax_exempt_pkg }, -           @cust_tax_exempt_pkg; -    } -  } +    } #if ref($tax_object)... +  } #foreach keys %$taxlisthash    #consolidate and create tax line items    warn "consolidating and generating...\n" if $DEBUG > 2;    foreach my $taxname ( keys %taxname ) { +    my @cust_bill_pkg_tax_location; +    my @cust_bill_pkg_tax_rate_location; +    my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({ +        'pkgnum'    => 0, +        'recur'     => 0, +        'sdate'     => '', +        'edate'     => '', +        'itemdesc'  => $taxname, +        'cust_bill_pkg_tax_location'      => \@cust_bill_pkg_tax_location, +        'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location, +    }); +      my $tax_total = 0;      my %seen = (); -    my @cust_bill_pkg_tax_location = (); -    my @cust_bill_pkg_tax_rate_location = ();      warn "adding $taxname\n" if $DEBUG > 1;      foreach my $taxitem ( @{ $taxname{$taxname} } ) { -      next if $seen{$taxitem}++; -      warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1; -      $tax_total += $tax_amount{$taxitem}; -      push @cust_bill_pkg_tax_location, -        map { new FS::cust_bill_pkg_tax_location $_ } -            @{ $tax_location{ $taxitem } }; -      push @cust_bill_pkg_tax_rate_location, -        map { new FS::cust_bill_pkg_tax_rate_location $_ } -            @{ $tax_rate_location{ $taxitem } }; +      if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) { +        # then we need to transfer the amount and the links from the +        # line item to the new one we're creating. +        $tax_total += $taxitem->setup; +        foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) { +          $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); +          push @cust_bill_pkg_tax_location, $link; +        } +      } else { +        # the tax_rate way +        next if $seen{$taxitem}++; +        warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1; +        $tax_total += $tax_amount{$taxitem}; +        push @cust_bill_pkg_tax_rate_location, +          map { new FS::cust_bill_pkg_tax_rate_location $_ } +              @{ $tax_rate_location{ $taxitem } }; +      }      }      next unless $tax_total; +    # we should really neverround this up...I guess it's okay if taxline  +    # already returns amounts with 2 decimal places      $tax_total = sprintf('%.2f', $tax_total ); +    $tax_cust_bill_pkg->set('setup', $tax_total);      my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,                                                     'disabled'     => '', @@ -890,19 +875,9 @@ sub calculate_taxes {        push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };      } +    $tax_cust_bill_pkg->set('display', \@display); -    push @tax_line_items, new FS::cust_bill_pkg { -      'pkgnum'   => 0, -      'setup'    => $tax_total, -      'recur'    => 0, -      'sdate'    => '', -      'edate'    => '', -      'itemdesc' => $taxname, -      'display'  => \@display, -      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location, -      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location, -    }; - +    push @tax_line_items, $tax_cust_bill_pkg;    }    \@tax_line_items; @@ -1184,11 +1159,23 @@ sub _make_lines {        # handle taxes        ### -      unless ( $discount_show_always ) { -	  my $error =  -	    $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options); -	  return $error if $error; -      } +      #unless ( $discount_show_always ) { # oh, for god's sake +      my $error = $self->_handle_taxes( +        $part_pkg, +        $taxlisthash, +        $cust_bill_pkg, +        $cust_pkg, +        $options{invoice_time}, +        $real_pkgpart, +        \%options # I have serious objections to this +      ); +      return $error if $error; +      #} + +      $cust_bill_pkg->set_display( +        part_pkg     => $part_pkg, +        real_pkgpart => $real_pkgpart, +      );        push @$cust_bill_pkgs, $cust_bill_pkg; @@ -1200,6 +1187,25 @@ sub _make_lines {  } +# This is _handle_taxes.  It's called once for each cust_bill_pkg generated +# from _make_lines, along with the part_pkg, cust_pkg, invoice time, the  +# non-overridden pkgpart, a flag indicating whether the package is being +# canceled, and a partridge in a pear tree. +# +# The most important argument is 'taxlisthash'.  This is shared across the  +# entire invoice.  It looks like this: +# { +#   'cust_main_county 1001' => [ [FS::cust_main_county], ... ], +#   'cust_main_county 1002' => [ [FS::cust_main_county], ... ], +# } +# +# 'cust_main_county' can also be 'tax_rate'.  The first object in the array +# is always the cust_main_county or tax_rate identified by the key. +# +# That "..." is a list of FS::cust_bill_pkg objects that will be fed to  +# the 'taxline' method to calculate the amount of the tax.  This doesn't +# happen until calculate_taxes, though. +  sub _handle_taxes {    my $self = shift;    my $part_pkg = shift; @@ -1212,175 +1218,152 @@ sub _handle_taxes {    local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; -  my %cust_bill_pkg = (); -  my %taxes = (); -     -  my @classes; -  #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; -  push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; -  push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); -  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); - -  my $exempt = $conf->exists('cust_class-tax_exempt') -                 ? ( $self->cust_class ? $self->cust_class->tax : '' ) -                 : $self->tax; -  # standardize this just to be sure -  $exempt = ($exempt eq 'Y') ? 'Y' : ''; - -  #if ( $exempt !~ /Y/i && $self->payby ne 'COMP' ) { -  if ( $self->payby ne 'COMP' ) { - -    if ( $conf->exists('enable_taxproducts') -         && ( scalar($part_pkg->part_pkg_taxoverride) -              || $part_pkg->has_taxproduct -            ) -       ) -    { +  return if ( $self->payby eq 'COMP' ); #dubious -      if ( !$exempt ) { +  if ( $conf->exists('enable_taxproducts') +       && ( scalar($part_pkg->part_pkg_taxoverride) +            || $part_pkg->has_taxproduct +          ) +     ) +    { -        foreach my $class (@classes) { -          my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg ); -          return $err_or_ref unless ref($err_or_ref); -          $taxes{$class} = $err_or_ref; -        } +    # EXTERNAL TAX RATES (via tax_rate) +    my %cust_bill_pkg = (); +    my %taxes = (); + +    my @classes; +    #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; +    push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; +    # debatable +    push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); +    push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); + +    my $exempt = $conf->exists('cust_class-tax_exempt') +                   ? ( $self->cust_class ? $self->cust_class->tax : '' ) +                   : $self->tax; +    # standardize this just to be sure +    $exempt = ($exempt eq 'Y') ? 'Y' : ''; +   +    if ( !$exempt ) { -        unless (exists $taxes{''}) { -          my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg ); -          return $err_or_ref unless ref($err_or_ref); -          $taxes{''} = $err_or_ref; -        } +      foreach my $class (@classes) { +        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg ); +        return $err_or_ref unless ref($err_or_ref); +        $taxes{$class} = $err_or_ref; +      } +      unless (exists $taxes{''}) { +        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg ); +        return $err_or_ref unless ref($err_or_ref); +        $taxes{''} = $err_or_ref;        } -    } else { # cust_main_county tax system +    } -      # We fetch taxes even if the customer is completely exempt, -      # because we need to record that fact. +    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; +    foreach my $key (keys %tax_cust_bill_pkg) { +      # $key is "setup", "recur", or a usage class name. ('' is a usage class.) +      # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of  +      # the line item. +      # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that +      # apply to $key-class charges. +      my @taxes = @{ $taxes{$key} || [] }; +      my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + +      my %localtaxlisthash = (); +      foreach my $tax ( @taxes ) { + +        # this is the tax identifier, not the taxname +        my $taxname = ref( $tax ). ' '. $tax->taxnum; +        $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum; +        # We need to create a separate $taxlisthash entry for each billpkgnum +        # on the invoice, so that cust_bill_pkg_tax_location records will +        # be linked correctly. + +        # $taxlisthash: keys are "setup", "recur", and usage classes. +        # Values are arrayrefs, first the tax object (cust_main_county +        # or tax_rate) and then any cust_bill_pkg objects that the  +        # tax applies to. +        $taxlisthash->{ $taxname } ||= [ $tax ]; +        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg; + +        $localtaxlisthash{ $taxname } ||= [ $tax ]; +        push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg; -      my @loc_keys = qw( district city county state country ); -      my $location = $cust_pkg->tax_location; -      my %taxhash = map { $_ => $location->$_ } @loc_keys; +      } -      $taxhash{'taxclass'} = $part_pkg->taxclass; +      warn "finding taxed taxes...\n" if $DEBUG > 2; +      foreach my $tax ( keys %localtaxlisthash ) { +        my $tax_object = shift @{ $localtaxlisthash{$tax} }; +        warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" +          if $DEBUG > 2; +        next unless $tax_object->can('tax_on_tax'); + +        foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { +          my $totname = ref( $tot ). ' '. $tot->taxnum; + +          warn "checking $totname which we call ". $tot->taxname. " as applicable\n" +            if $DEBUG > 2; +          next unless exists( $localtaxlisthash{ $totname } ); # only increase +                                                               # existing taxes +          warn "adding $totname to taxed taxes\n" if $DEBUG > 2; +          # we're calling taxline() right here?  wtf? +          my $hashref_or_error =  +            $tax_object->taxline( $localtaxlisthash{$tax}, +                                  'custnum'      => $self->custnum, +                                  'invoice_time' => $invoice_time, +                                ); +          return $hashref_or_error +            unless ref($hashref_or_error); +           +          $taxlisthash->{ $totname } ||= [ $tot ]; +          push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount}; -      warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; +        } +      } +    } -      my @taxes = (); # entries are cust_main_county objects -      my %taxhash_elim = %taxhash; -      my @elim = qw( district city county state ); -      do {  +  } else { -        #first try a match with taxclass -        @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); +    # INTERNAL TAX RATES (cust_main_county) -        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { -          #then try a match without taxclass -          my %no_taxclass = %taxhash_elim; -          $no_taxclass{ 'taxclass' } = ''; -          @taxes = qsearch( 'cust_main_county', \%no_taxclass ); -        } +    # We fetch taxes even if the customer is completely exempt, +    # because we need to record that fact. -        $taxhash_elim{ shift(@elim) } = ''; +    my @loc_keys = qw( district city county state country ); +    my $location = $cust_pkg->tax_location; +    my %taxhash = map { $_ => $location->$_ } @loc_keys; -      } while ( !scalar(@taxes) && scalar(@elim) ); +    $taxhash{'taxclass'} = $part_pkg->taxclass; -      foreach (@taxes) { -        # These could become cust_bill_pkg_tax_location records, -        # or cust_tax_exempt_pkg.  We'll decide later. -        # -        # The most important thing here: record which charge is being taxed. -        $_->set('billpkgnum',   $cust_bill_pkg->billpkgnum); -        # also these, for historical reasons -        $_->set('pkgnum',       $cust_pkg->pkgnum); -        $_->set('locationnum',  $cust_pkg->tax_locationnum); -      } +    warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; -      $taxes{''} = [ @taxes ]; -      $taxes{'setup'} = [ @taxes ]; -      $taxes{'recur'} = [ @taxes ]; -      $taxes{$_} = [ @taxes ] foreach (@classes); - -      # # maybe eliminate this entirely, along with all the 0% records -      # unless ( @taxes ) { -      #   return -      #     "fatal: can't find tax rate for state/county/country/taxclass ". -      #     join('/', map $taxhash{$_}, qw(state county country taxclass) ); -      # } - -    } #if $conf->exists('enable_taxproducts') ... - -  } # if $self->payby eq 'COMP' - -  #what's this doing in the middle of _handle_taxes?  probably should split -  #this into three parts above in _make_lines -  $cust_bill_pkg->set_display(   part_pkg     => $part_pkg, -                                 real_pkgpart => $real_pkgpart, -                             ); - -  my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; -  foreach my $key (keys %tax_cust_bill_pkg) { -    # $key is "setup", "recur", or a usage class name. ('' is a usage class.) -    # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of  -    # the line item. -    # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that -    # apply to $key-class charges. -    my @taxes = @{ $taxes{$key} || [] }; -    my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; - -    my %localtaxlisthash = (); -    foreach my $tax ( @taxes ) { - -      # this is the tax identifier, not the taxname -      my $taxname = ref( $tax ). ' '. $tax->taxnum; -      $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum; -      # We need to create a separate $taxlisthash entry for each billpkgnum -      # on the invoice, so that cust_bill_pkg_tax_location records will -      # be linked correctly. - -      # $taxlisthash: keys are "setup", "recur", and usage classes. -      # Values are arrayrefs, first the tax object (cust_main_county -      # or tax_rate) and then any cust_bill_pkg objects that the  -      # tax applies to. -      $taxlisthash->{ $taxname } ||= [ $tax ]; -      push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg; - -      $localtaxlisthash{ $taxname } ||= [ $tax ]; -      push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg; +    my @taxes = (); # entries are cust_main_county objects +    my %taxhash_elim = %taxhash; +    my @elim = qw( district city county state ); +    do {  -    } +      #first try a match with taxclass +      @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); -    warn "finding taxed taxes...\n" if $DEBUG > 2; -    foreach my $tax ( keys %localtaxlisthash ) { -      my $tax_object = shift @{ $localtaxlisthash{$tax} }; -      warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" -        if $DEBUG > 2; -      next unless $tax_object->can('tax_on_tax'); +      if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { +        #then try a match without taxclass +        my %no_taxclass = %taxhash_elim; +        $no_taxclass{ 'taxclass' } = ''; +        @taxes = qsearch( 'cust_main_county', \%no_taxclass ); +      } -      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { -        my $totname = ref( $tot ). ' '. $tot->taxnum; +      $taxhash_elim{ shift(@elim) } = ''; -        warn "checking $totname which we call ". $tot->taxname. " as applicable\n" -          if $DEBUG > 2; -        next unless exists( $localtaxlisthash{ $totname } ); # only increase -                                                             # existing taxes -        warn "adding $totname to taxed taxes\n" if $DEBUG > 2; -        my $hashref_or_error =  -          $tax_object->taxline( $localtaxlisthash{$tax}, -                                'custnum'      => $self->custnum, -                                'invoice_time' => $invoice_time, -                              ); -        return $hashref_or_error -          unless ref($hashref_or_error); -         -        $taxlisthash->{ $totname } ||= [ $tot ]; -        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount}; +    } while ( !scalar(@taxes) && scalar(@elim) ); -      } +    foreach (@taxes) { +      my $tax_id = 'cust_main_county '.$_->taxnum; +      $taxlisthash->{$tax_id} ||= [ $_ ]; +      push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg;      }    } -    '';  } diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 80063debb..804969b16 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -757,19 +757,6 @@ sub fake_bop {       return "Error: No error; test failure requested with fake_failure";    } -  #my $paybatch = ''; -  #if ( $payment_gateway->gatewaynum ) { # agent override -  #  $paybatch = $payment_gateway->gatewaynum. '-'; -  #} -  # -  #$paybatch .= "$processor:". $transaction->authorization; -  # -  #$paybatch .= ':'. $transaction->order_number -  #  if $transaction->can('order_number') -  #  && length($transaction->order_number); - -  my $paybatch = 'FakeProcessor:54:32'; -    my $cust_pay = new FS::cust_pay ( {       'custnum'  => $self->custnum,       'invnum'   => $options{'invnum'}, @@ -778,9 +765,11 @@ sub fake_bop {       'payby'    => $bop_method2payby{$options{method}},       #'payinfo'  => $payinfo,       'payinfo'  => '4111111111111111', -     'paybatch' => $paybatch,       #'paydate'  => $paydate,       'paydate'  => '2012-05-01', +     'processor'      => 'FakeProcessor', +     'auth'           => '54', +     'order_number'   => '32',    } );    $cust_pay->payunique( $options{payunique} ) if length($options{payunique}); @@ -841,17 +830,8 @@ sub _realtime_bop_result {    if ( $transaction->is_success() ) { -    my $paybatch = ''; -    if ( $payment_gateway->gatewaynum ) { # agent override -      $paybatch = $payment_gateway->gatewaynum. '-'; -    } - -    $paybatch .= $payment_gateway->gateway_module. ":". -      $transaction->authorization; - -    $paybatch .= ':'. $transaction->order_number -      if $transaction->can('order_number') -      && length($transaction->order_number); +    my $order_number = $transaction->order_number +      if $transaction->can('order_number');      my $cust_pay = new FS::cust_pay ( {         'custnum'  => $self->custnum, @@ -860,10 +840,14 @@ sub _realtime_bop_result {         '_date'    => '',         'payby'    => $cust_pay_pending->payby,         'payinfo'  => $options{'payinfo'}, -       'paybatch' => $paybatch,         'paydate'  => $cust_pay_pending->paydate,         'pkgnum'   => $cust_pay_pending->pkgnum, -       'discount_term' => $options{'discount_term'}, +       'discount_term'  => $options{'discount_term'}, +       'gatewaynum'     => ($payment_gateway->gatewaynum || ''), +       'processor'      => $payment_gateway->gateway_module, +       'auth'           => $transaction->authorization, +       'order_number'   => $order_number || '', +      } );      #doesn't hurt to know, even though the dup check is in cust_pay_pending now      $cust_pay->payunique( $options{payunique} ) @@ -1363,6 +1347,7 @@ sub realtime_refund_bop {    my( $processor, $login, $password, @bop_options, $namespace ) ;    my( $auth, $order_number ) = ( '', '', '' ); +  my $gatewaynum = '';    if ( $options{'paynum'} ) { @@ -1371,11 +1356,22 @@ sub realtime_refund_bop {        or return "Unknown paynum $options{'paynum'}";      $amount ||= $cust_pay->paid; -    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/ -      or return "Can't parse paybatch for paynum $options{'paynum'}: ". -                $cust_pay->paybatch; -    my $gatewaynum = ''; -    ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 ); +    if ( $cust_pay->get('processor') ) { +      ($gatewaynum, $processor, $auth, $order_number) = +      ( +        $cust_pay->gatewaynum, +        $cust_pay->processor, +        $cust_pay->auth, +        $cust_pay->order_number, +      ); +    } else { +      # this payment wasn't upgraded, which probably means this won't work, +      # but try it anyway +      $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/ +        or return "Can't parse paybatch for paynum $options{'paynum'}: ". +                  $cust_pay->paybatch; +      ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 ); +    }      if ( $gatewaynum ) { #gateway for the payment to be refunded @@ -1605,9 +1601,7 @@ sub realtime_refund_bop {    return "$processor error: ". $refund->error_message      unless $refund->is_success(); -  my $paybatch = "$processor:". $refund->authorization; -  $paybatch .= ':'. $refund->order_number -    if $refund->can('order_number') && $refund->order_number; +  $order_number = $refund->order_number if $refund->can('order_number');    while ( $cust_pay && $cust_pay->unapplied < $amount ) {      my @cust_bill_pay = $cust_pay->cust_bill_pay; @@ -1624,8 +1618,11 @@ sub realtime_refund_bop {      '_date'    => '',      'payby'    => $bop_method2payby{$options{method}},      'payinfo'  => $payinfo, -    'paybatch' => $paybatch,      'reason'   => $options{'reason'} || 'card or ACH refund', +    'gatewaynum'    => $gatewaynum, # may be null +    'processor'     => $processor, +    'auth'          => $refund->authorization, +    'order_number'  => $order_number,    } );    my $error = $cust_refund->insert;    if ( $error ) { diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm index 87c1ca730..db6be751d 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -258,10 +258,15 @@ sub _list_sql {  =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ] -Returns an hashref of a name and an amount of tax calculated for the  -line items (L<FS::cust_bill_pkg> objects) in TAXABLES_ARRAYREF.  The line  -items must come from the same invoice.  Returns a scalar error message  -on error. +Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable +line items, and returns a new L<FS::cust_bill_pkg> object representing +the tax on them under this tax rate. + +This will have a pseudo-field, "cust_bill_pkg_tax_location", containing  +an arrayref of L<FS::cust_bill_pkg_tax_location> objects.  Each of these  +will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one +of the taxable items.  All of these links must be resolved as the objects +are inserted.  In addition to calculating the tax for the line items, this will calculate  any appropriate tax exemptions and attach them to the line items. @@ -275,8 +280,7 @@ tax exemption limit if there is one.  =cut -# XXX this should just return a cust_bill_pkg object for the tax, -# but that requires changing stuff in tax_rate.pm also. +# XXX change tax_rate.pm to work like this  sub taxline {    my( $self, $taxables, %opt ) = @_; @@ -294,7 +298,8 @@ sub taxline {    my $dbh = dbh;    my $name = $self->taxname || 'Tax'; -  my $amount = 0; +  my $taxable_cents = 0; +  my $tax_cents = 0;    my $cust_bill = $taxables->[0]->cust_bill;    my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'}; @@ -325,6 +330,15 @@ sub taxline {    push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }      for @$taxables; +  my $tax_item = FS::cust_bill_pkg->new({ +      'pkgnum'    => 0, +      'recur'     => 0, +      'sdate'     => '', +      'edate'     => '', +      'itemdesc'  => $name, +  }); +  my @tax_location; +    foreach my $cust_bill_pkg (@$taxables) {      my $cust_pkg  = $cust_bill_pkg->cust_pkg; @@ -472,37 +486,47 @@ sub taxline {      $_->taxnum($self->taxnum) foreach @new_exemptions; -    #if ( $cust_bill_pkg->billpkgnum ) { - -      #no, need to do this to e.g. calculate tax credit amounts -      #die "tried to calculate tax exemptions on a previously billed line item\n"; - -      # this is unnecessary -#      foreach my $cust_tax_exempt_pkg (@new_exemptions) { -#        my $error = $cust_tax_exempt_pkg->insert; -#        if ( $error ) { -#          $dbh->rollback if $oldAutoCommit; -#          return "can't insert cust_tax_exempt_pkg: $error"; -#        } -#      } -    #} -      # attach them to the line item      push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;      push @existing_exemptions, @new_exemptions; -    # If we were smart, we'd also generate a cust_bill_pkg_tax_location  -    # record at this point, but that would require redesigning more stuff.      $taxable_charged = sprintf( "%.2f", $taxable_charged); - -    $amount += $taxable_charged * $self->tax / 100; +    next if $taxable_charged == 0; + +    my $this_tax_cents = int($taxable_charged * $self->tax); +    my $location = FS::cust_bill_pkg_tax_location->new({ +        'taxnum'      => $self->taxnum, +        'taxtype'     => ref($self), +        'cents'       => $this_tax_cents, +        'taxable_cust_bill_pkg' => $cust_bill_pkg, +        'tax_cust_bill_pkg'     => $tax_item, +    }); +    push @tax_location, $location; + +    $taxable_cents += $taxable_charged; +    $tax_cents += $this_tax_cents;    } #foreach $cust_bill_pkg - -  return { -    'name'   => $name, -    'amount' => $amount, -  }; - +   +  # now round and distribute +  my $extra_cents = sprintf('%.2f', $taxable_cents * $self->tax / 100) * 100 +                    - $tax_cents; +  if ( $extra_cents < 0 ) { +    die "nonsense extra_cents value $extra_cents"; # because seriously, wtf +  } +  $tax_cents += $extra_cents; +  my $i = 0; +  foreach (@tax_location) { # can never require more than a single pass, yes? +    my $cents = $_->get('cents'); +    if ( $extra_cents > 0 ) { +      $cents++; +      $extra_cents--; +    } +    $_->set('amount', sprintf('%.2f', $cents/100)); +  } +  $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100)); +  $tax_item->set('cust_bill_pkg_tax_location', \@tax_location); +   +  return $tax_item;  }  =back diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm index d28997ccd..4535aadb2 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -100,7 +100,7 @@ Masked payinfo (See L<FS::payinfo_Mixin> for how this works)  =item paybatch -text field for tracking card processing or other batch grouping +obsolete text field for tracking card processing or other batch grouping  =item payunique @@ -130,11 +130,32 @@ The deposit account number.  The teller number. -=item pay_batch +=item batchnum  The number of the batch this payment came from (see L<FS::pay_batch>),   or null if it was processed through a realtime gateway or entered manually. +=item gatewaynum + +The number of the realtime or batch gateway L<FS::payment_gateway>) this  +payment was processed through.  Null if it was entered manually or processed +by the "system default" gateway, which doesn't have a number. + +=item processor + +The name of the processor module (Business::OnlinePayment, ::BatchPayment,  +or ::OnlineThirdPartyPayment subclass) used for this payment.  Slightly +redundant with C<gatewaynum>. + +=item auth + +The authorization number returned by the credit card network. + +=item order_number + +The transaction ID returned by the gateway, if any.  This is usually what  +you would use to initiate a void or refund of the payment. +  =back  =head1 METHODS @@ -878,6 +899,8 @@ sub _upgrade_data {  #class method    warn "$me upgrading $class\n" if $DEBUG; +  local $FS::payinfo_Mixin::ignore_masked_payinfo = 1; +    ##    # otaker/ivan upgrade    ## @@ -1004,6 +1027,33 @@ sub _upgrade_data {  #class method      if $error;    } +  ### +  # migrate gateway info from the misused 'paybatch' field +  ### + +  # not only cust_pay, but also voided and refunded payments +  if (!FS::upgrade_journal->is_done('cust_pay__parse_paybatch')) { +    # really inefficient, but again, only has to run once +    foreach my $table (qw(cust_pay cust_pay_void cust_refund)) { +      foreach my $object ( qsearch({ +            table     => $table, +            extra_sql => "WHERE payby IN('CARD','CHEK') ". +                         "AND paybatch IS NOT NULL", +          }) ) +      { +        my $parsed = $object->_parse_paybatch; +        if (keys %$parsed) { +          $object->set($_ => $parsed->{$_}) foreach keys %$parsed; +          $object->set('paybatch', ''); +          my $error = $object->replace; +          warn "error parsing CARD/CHEK paybatch fields on $object #". +            $object->get($object->primary_key).":\n  $error\n" +            if $error; +        } +      } #$object +    } #$table +    FS::upgrade_journal->set_done('cust_pay__parse_paybatch'); +  }  }  =back diff --git a/FS/FS/cust_pay_void.pm b/FS/FS/cust_pay_void.pm index bebcfd4cc..42fc2966c 100644 --- a/FS/FS/cust_pay_void.pm +++ b/FS/FS/cust_pay_void.pm @@ -1,7 +1,7 @@  package FS::cust_pay_void;   use strict; -use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin +use base qw( FS::otaker_Mixin FS::payinfo_transaction_Mixin FS::cust_main_Mixin               FS::Record );  use vars qw( @encrypted_fields $otaker_upgrade_kludge );  use Business::CreditCard; diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm index 7df7a557a..45a170ba0 100644 --- a/FS/FS/cust_refund.pm +++ b/FS/FS/cust_refund.pm @@ -87,6 +87,11 @@ order taker (see L<FS::access_user>  books closed flag, empty or `Y' +=item gatewaynum, processor, auth, order_number + +Same as for L<FS::cust_pay>, but specifically the result of realtime  +authorization of the refund. +  =back  =head1 METHODS diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 7b713efd3..9879a3abd 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -5,6 +5,8 @@ use Business::CreditCard;  use FS::payby;  use FS::Record qw(qsearch); +use vars qw($ignore_masked_payinfo); +  =head1 NAME  FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.   @@ -207,17 +209,21 @@ sub payinfo_check {    if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {      my $payinfo = $self->payinfo; -    $payinfo =~ s/\D//g; -    $self->payinfo($payinfo); -    if ( $self->payinfo ) { -      $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/ -        or return "Illegal (mistyped?) credit card number (payinfo)"; -      $self->payinfo($1); -      validate($self->payinfo) or return "Illegal credit card number"; -      return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token -                                 && cardtype($self->payinfo) eq "Unknown"; +    if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) { +      # allow it      } else { -      $self->payinfo('N/A'); #??? +      $payinfo =~ s/\D//g; +      $self->payinfo($payinfo); +      if ( $self->payinfo ) { +        $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/ +          or return "Illegal (mistyped?) credit card number (payinfo)"; +        $self->payinfo($1); +        validate($self->payinfo) or return "Illegal credit card number"; +        return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token +                                   && cardtype($self->payinfo) eq "Unknown"; +      } else { +        $self->payinfo('N/A'); #??? +      }      }    } else {      if ( $self->is_encrypted($self->payinfo) ) { @@ -230,8 +236,6 @@ sub payinfo_check {      }    } -  ''; -  }  =item payby_payinfo_pretty diff --git a/FS/FS/payinfo_transaction_Mixin.pm b/FS/FS/payinfo_transaction_Mixin.pm index 19419de1c..093891e93 100644 --- a/FS/FS/payinfo_transaction_Mixin.pm +++ b/FS/FS/payinfo_transaction_Mixin.pm @@ -23,7 +23,8 @@ use vars qw(@ISA);  =head1 DESCRIPTION  This is a mixin class for records that represent transactions: that contain -payinfo and paybatch.  Currently FS::cust_pay and FS::cust_refund +payinfo and realtime result fields (gatewaynum, processor, authorization, +order_number).  Currently FS::cust_pay, FS::cust_refund, and FS::cust_pay_void.  =head1 METHODS @@ -55,32 +56,8 @@ sub payby_name {    }  } -=item gatewaynum +# We keep _parse_paybatch just because the upgrade needs it. -Returns a gatewaynum for the processing gateway. - -=item processor - -Returns a name for the processing gateway. - -=item authorization - -Returns a name for the processing gateway. - -=item order_number - -Returns a name for the processing gateway. - -=cut - -sub gatewaynum    { shift->_parse_paybatch->{'gatewaynum'}; } -sub processor     { shift->_parse_paybatch->{'processor'}; } -sub authorization { shift->_parse_paybatch->{'authorization'}; } -sub order_number  { shift->_parse_paybatch->{'order_number'}; } - -#sucks that this stuff is in paybatch like this in the first place, -#but at least other code can start to use new field names -#(code nicked from FS::cust_main::realtime_refund_bop)  sub _parse_paybatch {    my $self = shift; @@ -112,6 +89,33 @@ sub _parse_paybatch {  } +# because we can't actually name the field 'authorization' (reserved word) +sub authorization { +  my $self = shift; +  $self->auth(@_); +} + +=item payinfo_check + +Checks the validity of the realtime payment fields (gatewaynum, processor, +auth, and order_number) as well as payby and payinfo + +=cut + +sub payinfo_check { +  my $self = shift; + +  # All of these can be null, so in principle this could go in payinfo_Mixin. + +  $self->SUPER::payinfo_check() +  || $self->ut_numbern('gatewaynum') +  # not ut_foreign_keyn, it causes upgrades to fail +  || $self->ut_alphan('processor') +  || $self->ut_textn('auth') +  || $self->ut_textn('order_number') +  || ''; +} +  =back  =head1 SEE ALSO diff --git a/FS/bin/freeside-ipifony-download b/FS/bin/freeside-ipifony-download index 64905e193..837cc3329 100644 --- a/FS/bin/freeside-ipifony-download +++ b/FS/bin/freeside-ipifony-download @@ -12,7 +12,18 @@ use FS::Conf;  use Text::CSV;  my %opt; -getopts('va:P:C:T:', \%opt); +getopts('va:P:C:e:', \%opt); + +# Product codes that are subject to flat rate E911 charges.  For these  +# products, the'quantity' field represents the number of lines. +my @E911_CODES = ( 'V-HPBX', 'V-TRUNK' ); + +# Map TAXNONVOICE/TAXVOICE to Freeside taxclass names +my %TAXCLASSES = ( +  'TAXNONVOICE' => 'Other', +  'TAXVOICE'   => 'VoIP', +); +    #$Net::SFTP::Foreign::debug = -1;  sub HELP_MESSAGE { ' @@ -22,7 +33,7 @@ sub HELP_MESSAGE { '          [ -a archivedir ]          [ -P port ]          [ -C category ] -        [ -T taxclass ] +        [ -e pkgpart ]          freesideuser sftpuser@hostname[:path]  ' } @@ -30,12 +41,14 @@ my @fields = (    'custnum',    'date_desc',    'quantity', -  'amount', +  'unit_price',    'classname', +  'taxclass',  );  my $user = shift or die &HELP_MESSAGE; -adminsuidsetup $user; +my $dbh = adminsuidsetup $user; +$FS::UID::AutoCommit = 0;  # for statistics  my $num_charges = 0; @@ -51,6 +64,16 @@ if ( $opt{a} ) {      unless -w $opt{a};  } +my $e911_part_pkg; +if ( $opt{e} ) { +  $e911_part_pkg = FS::part_pkg->by_key($opt{e}) +    or die "E911 pkgpart $opt{e} not found.\n"; + +  if ( $e911_part_pkg->base_recur > 0 or $e911_part_pkg->freq ) { +    die "E911 pkgpart $opt{e} must be a one-time charge.\n"; +  } +} +  my $categorynum = '';  if ( $opt{C} ) {    # find this category (don't auto-create it, it should exist already) @@ -61,8 +84,6 @@ if ( $opt{C} ) {    $categorynum = $category->categorynum;  } -my $taxclass = $opt{T} || ''; -  #my $tmpdir = File::Temp->newdir();  my $tmpdir = tempdir( CLEANUP => 1 ); #DIR=>somewhere? @@ -88,7 +109,7 @@ my $sftp = Net::SFTP::Foreign->new(    port      => $port,    # for now we don't support passwords. use authorized_keys.    timeout   => 30, -  more      => ($opt{v} ? '-v' : ''), +  #more      => ($opt{v} ? '-v' : ''),  );  die "failed to connect to '$sftpuser\@$host'\n(".$sftp->error.")\n"    if $sftp->error; @@ -100,6 +121,12 @@ if (!@$files) {    print STDERR "No charge files found.\n" if $opt{v};    exit(-1);  } + +my %cust_main; # cache +my %e911_qty; # custnum => sum of E911-subject quantity + +my %is_e911 = map {$_ => 1} @E911_CODES; +  FILE: foreach my $filename (@$files) {    print STDERR "Retrieving $filename\n" if $opt{v};    $sftp->get("$filename", "$tmpdir/$filename"); @@ -133,7 +160,7 @@ FILE: foreach my $filename (@$files) {    open my $fh, "<$tmpdir/$filename";    my $header = <$fh>; -  if ($header !~ /^cust_id/) { +  if ($header !~ /^"cust_id"/) {      warn "warning: $filename has incorrect header row:\n$header\n";      # but try anyway    } @@ -145,7 +172,8 @@ FILE: foreach my $filename (@$files) {        next FILE;      };      @hash{@fields} = $csv->fields(); -    my $cust_main = FS::cust_main->by_key($hash{custnum}); +    my $cust_main =  +      $cust_main{$hash{custnum}} ||= FS::cust_main->by_key($hash{custnum});      if (!$cust_main) {        warn "customer #$hash{custnum} not found\n";        next; @@ -153,13 +181,14 @@ FILE: foreach my $filename (@$files) {      print STDERR "Found customer #$hash{custnum}: ".$cust_main->name."\n"        if $opt{v}; +    my $amount = sprintf('%.2f',$hash{quantity} * $hash{unit_price});      # construct arguments for $cust_main->charge -    my %opt = ( -      amount      => $hash{amount}, +    my %charge_opt = ( +      amount      => $amount,        quantity    => $hash{quantity},        start_date  => $cust_main->next_bill_date,        pkg         => $hash{date_desc}, -      taxclass    => $taxclass, +      taxclass    => $TAXCLASSES{ $hash{taxclass} },      );      if (my $classname = $hash{classname}) {        if (!exists($classnum_of{$classname}) ) { @@ -182,11 +211,11 @@ FILE: foreach my $filename (@$files) {          $classnum_of{$classname} = $pkg_class->classnum;        } -      $opt{classnum} = $classnum_of{$classname}; +      $charge_opt{classnum} = $classnum_of{$classname};      } -    print STDERR "  Charging $hash{amount}\n" +    print STDERR "  Charging $hash{unit_price} * $hash{quantity}\n"        if $opt{v}; -    my $error = $cust_main->charge(\%opt); +    my $error = $cust_main->charge(\%charge_opt);      if ($error) {        warn "Error creating charge: $error" if $error;        $num_errors++; @@ -194,48 +223,94 @@ FILE: foreach my $filename (@$files) {        $num_charges++;        $sum_charges += $hash{amount};      } + +    if ( $opt{e} and $is_e911{$hash{classname}} ) { +      $e911_qty{$hash{custnum}} ||= 0; +      $e911_qty{$hash{custnum}} += $hash{quantity}; +    }    } #while $line    close $fh;  } #FILE +# Order E911 packages +my $num_e911 = 0; +my $num_lines = 0; +foreach my $custnum ( keys (%e911_qty) ) { +  my $cust_main = $cust_main{$custnum}; +  my $quantity = $e911_qty{$custnum}; +  next if $quantity == 0; +  my $cust_pkg = FS::cust_pkg->new({ +      pkgpart     => $opt{e}, +      custnum     => $custnum, +      start_date  => $cust_main->next_bill_date, +      quantity    => $quantity, +  }); +  my $error = $cust_main->order_pkg({ cust_pkg => $cust_pkg }); +  if ( $error ) { +    warn "Error creating e911 charge for customer $custnum: $error\n"; +    $num_errors++; +  } else { +    $num_e911++; +    $num_lines += $quantity; +  } +} + +$dbh->commit; +  if ($opt{v}) {    print STDERR "  Finished!    Processed files: @$files    Created charges: $num_charges    Sum of charges: \$".sprintf('%0.2f', $sum_charges)." +  E911 charges: $num_e911 +  E911 lines: $num_lines    Errors: $num_errors  ";  }  =head1 NAME -freeside-eftca-download - Retrieve payment batch responses from EFT Canada. +freeside-ipifony-download - Download and import invoice items from IPifony.  =head1 SYNOPSIS -  freeside-eftca-download [ -v ] [ -a archivedir ] user +      freeside-ipifony-download  +        [ -v ] +        [ -a archivedir ] +        [ -P port ] +        [ -C category ] +        [ -T taxclass ] +        [ -e pkgpart ] +        freesideuser sftpuser@hostname[:path] -=head1 DESCRIPTION +=head1 REQUIRED PARAMETERS -Command line tool to download returned payment reports from the EFT Canada  -gateway and void the returned payments.  Uses the login and password from  -'batchconfig-eft_canada'. +I<freesideuser>: the Freeside user to run as. --v: Be verbose. +I<sftpuser>: the SFTP user to connect as.  The 'freeside' system user should  +have an authorization key to connect as that user. --a directory: Archive response files in the provided directory. +I<hostname>: the SFTP server. + +=head1 OPTIONAL PARAMETERS + +-v: Be verbose. -user: freeside username +-a I<archivedir>: Save a copy of the downloaded file to I<archivedir>. -=head1 BUGS +-P I<port>: Connect to that TCP port. -You need to manually SFTP to ftp.eftcanada.com from the freeside account  -and accept their key before running this script. +-C I<category>: The name of a package category to use when creating package +classes. -=head1 SEE ALSO +-e I<pkgpart>: The pkgpart (L<FS::part_pkg>) to use for E911 charges.  A  +package of this type will be ordered for each invoice that has E911-subject +line items.  The 'quantity' field on this package will be set to the total  +quantity of those line items. -L<FS::pay_batch> +The E911 package must be a one-time package (flat rate, no frequency, no  +recurring fee) with setup fee equal to the fee per line.  =cut diff --git a/FS/bin/freeside-void-payments b/FS/bin/freeside-void-payments index 8c1f3dbdf..49b74d388 100755 --- a/FS/bin/freeside-void-payments +++ b/FS/bin/freeside-void-payments @@ -90,8 +90,11 @@ my $notfound = 0;  my $canceled = 0;  print "Voiding ".scalar(@auths)." transactions:\n" if $opt{'v'};  foreach my $authnum (@auths) { -  my $paybatch = $gatewaynum . $processor . ':' . $authnum; -  my $cust_pay = qsearchs('cust_pay', { paybatch => $paybatch } ); +  my $cust_pay = qsearchs('cust_pay', { +     gatewaynum     => $gatewaynum, +     processor      => $processor, +     authorization  => $authnum, +  });    my $error;    my $cancel_error;    if($cust_pay) { @@ -103,7 +106,11 @@ foreach my $authnum (@auths) {      }    }    else { -    my $cpv = qsearchs('cust_pay_void', { paybatch => $paybatch }); +    my $cpv = qsearchs('cust_pay_void', { +       gatewaynum     => $gatewaynum, +       processor      => $processor, +       authorization  => $authnum, +    });      if($cpv) {        $error = 'already voided '.time2str('%Y-%m-%d', $cpv->void_date) .           ' by ' . $cpv->otaker; | 
