diff options
Diffstat (limited to 'FS')
| -rw-r--r-- | FS/FS/AccessRight.pm | 2 | ||||
| -rw-r--r-- | FS/FS/ClientAPI/MyAccount.pm | 14 | ||||
| -rw-r--r-- | FS/FS/ConfDefaults.pm | 26 | ||||
| -rw-r--r-- | FS/FS/Test.pm | 30 | ||||
| -rw-r--r-- | FS/FS/UI/Web.pm | 5 | ||||
| -rw-r--r-- | FS/FS/Upgrade.pm | 12 | ||||
| -rw-r--r-- | FS/FS/cdr.pm | 14 | ||||
| -rw-r--r-- | FS/FS/cust_main.pm | 29 | ||||
| -rw-r--r-- | FS/FS/cust_main/Billing_Realtime.pm | 127 | ||||
| -rw-r--r-- | FS/FS/cust_pay_pending.pm | 13 | ||||
| -rw-r--r-- | FS/FS/cust_payby.pm | 14 | ||||
| -rw-r--r-- | FS/FS/cust_pkg.pm | 10 | ||||
| -rw-r--r-- | FS/FS/cust_svc.pm | 12 | ||||
| -rw-r--r-- | FS/FS/detail_format/sum_duration_accountcode.pm | 69 | ||||
| -rw-r--r-- | FS/FS/log_context.pm | 1 | ||||
| -rw-r--r-- | FS/FS/part_pkg/prorate_Mixin.pm | 43 | ||||
| -rw-r--r-- | FS/FS/part_pkg/voip_cdr.pm | 5 | ||||
| -rw-r--r-- | FS/FS/part_pkg/voip_inbound.pm | 1 | ||||
| -rw-r--r-- | FS/FS/part_pkg/voip_sqlradacct.pm | 3 | ||||
| -rw-r--r-- | FS/FS/part_pkg/voip_tiered.pm | 1 | ||||
| -rw-r--r-- | FS/FS/password_history.pm | 23 | ||||
| -rw-r--r-- | FS/bin/freeside-ipifony-download | 14 | ||||
| -rwxr-xr-x | FS/t/suite/06-prorate_defer_bill.t | 92 | ||||
| -rwxr-xr-x | FS/t/suite/07-pkg_change_location.t | 82 |
24 files changed, 573 insertions, 69 deletions
diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm index 7096db535..89e50aa00 100644 --- a/FS/FS/AccessRight.pm +++ b/FS/FS/AccessRight.pm @@ -153,7 +153,7 @@ tie my %rights, 'Tie::IxHash', 'Make appointment', 'View package definition costs', #NEWNEW 'Change package start date', - 'Add/remove package contract end date', + 'Change package contract end date', ], ### diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index ecac223da..986306524 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -1856,6 +1856,20 @@ sub list_svcs { # would it make sense to put this in a svc_* method? + if (!$hide_usage and grep(/^$svcdb$/, qw(svc_acct svc_broadband)) and $part_svc->part_export_usage) { + my $last_bill = $cust_pkg->last_bill || 0; + my $now = time; + my $up_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctInputOctets'); + my $down_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctOutputOctets'); + %hash = ( + %hash, + 'seconds_used' => $cust_svc->seconds_since_sqlradacct($last_bill,$now), + 'upbytes_used' => display_bytecount($up_used), + 'downbytes_used' => display_bytecount($down_used), + 'totalbytes_used' => display_bytecount($up_used + $down_used) + ); + } + if ( $svcdb eq 'svc_acct' ) { foreach (qw(username email finger seconds)) { $hash{$_} = $svc_x->$_; diff --git a/FS/FS/ConfDefaults.pm b/FS/FS/ConfDefaults.pm index 4c37175c3..2fa834439 100644 --- a/FS/FS/ConfDefaults.pm +++ b/FS/FS/ConfDefaults.pm @@ -56,29 +56,23 @@ sub cust_fields_avail { ( 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s)' => 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s)', - 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type' => - 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type', - - 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' => - 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type | Current Balance', + 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Current Balance' => + 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Current Balance', 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s)' => 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s)', - 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type' => - 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type', - - 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' => - 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance', + 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' => + 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance', - 'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' => - 'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance', + 'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' => + 'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance', - 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance' => - 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance', + 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance' => + 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance', - 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance | Advertising Source' => - 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance | Advertising Source', + 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance | Advertising Source' => + 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance | Advertising Source', 'Invoicing email(s)' => 'Invoicing email(s)', 'Cust# | Invoicing email(s)' => 'custnum | Invoicing email(s)', diff --git a/FS/FS/Test.pm b/FS/FS/Test.pm index 9854b94fa..9c77417fe 100644 --- a/FS/FS/Test.pm +++ b/FS/FS/Test.pm @@ -235,4 +235,34 @@ sub qsearchs { FS::Record::qsearchs(@_); } +=item new_customer FIRSTNAME + +Returns an L<FS::cust_main> object full of default test data, ready to be inserted. +This doesn't insert the customer, because you might want to change some things first. +FIRSTNAME is recommended so you know which test the customer was used for. + +=cut + +sub new_customer { + my $self = shift; + my $first = shift || 'No Name'; + my $location = FS::cust_location->new({ + address1 => '123 Example Street', + city => 'Sacramento', + state => 'CA', + country => 'US', + zip => '94901', + }); + my $cust = FS::cust_main->new({ + agentnum => 1, + refnum => 1, + last => 'Customer', + first => $first, + invoice_email => 'newcustomer@fake.freeside.biz', + bill_location => $location, + ship_location => $location, + }); + $cust; +} + 1; # End of FS::Test diff --git a/FS/FS/UI/Web.pm b/FS/FS/UI/Web.pm index e07e682ba..04aeda103 100644 --- a/FS/FS/UI/Web.pm +++ b/FS/FS/UI/Web.pm @@ -343,7 +343,8 @@ sub cust_header { '(service) Latitude' => 'ship_latitude', '(service) Longitude' => 'ship_longitude', 'Invoicing email(s)' => 'invoicing_list_emailonly_scalar', - 'Payment Type' => 'cust_payby', +# FS::Upgrade::upgrade_config removes this from existing cust-fields settings +# 'Payment Type' => 'cust_payby', 'Current Balance' => 'current_balance', 'Agent Cust#' => 'agent_custid', 'Advertising Source' => 'referral', @@ -447,8 +448,6 @@ sub cust_sql_fields { foreach my $field (qw(daytime night mobile fax )) { push @fields, $field if (grep { $_ eq $field } @cust_fields); } - push @fields, "payby AS cust_payby" - if grep { $_ eq 'cust_payby' } @cust_fields; push @fields, 'agent_custid'; my @extra_fields = (); diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index eb2587b34..a374d391d 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -170,6 +170,14 @@ If you need to continue using the old Form 477 report, turn on the $conf->delete('unsuspendauto'); } + if ($conf->config('cust-fields') =~ / \| Payment Type/) { + my $cust_fields = $conf->config('cust-fields'); + # so we can potentially use 'Payment Types' or somesuch in the future + $cust_fields =~ s/ \| Payment Type( \|)/$1/; + $cust_fields =~ s/ \| Payment Type$//; + $conf->set('cust-fields',$cust_fields); + } + enable_banned_pay_pad() unless length($conf->config('banned_pay-pad')); } @@ -523,7 +531,9 @@ sub upgrade_schema_data { 'cust_bill_pkg_detail' => [], #add necessary columns to RT schema 'TicketSystem' => [], - + #remove possible dangling records + 'password_history' => [], + 'cust_pay_pending' => [], ; \%hash; diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm index b3cceb4aa..a2b9a8ccb 100644 --- a/FS/FS/cdr.pm +++ b/FS/FS/cdr.pm @@ -927,8 +927,10 @@ sub rate_prefix { # by default, set the included minutes for this region/time to # what's in the rate_detail - $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included - unless exists $included_min->{$regionnum}{$ratetimenum}; + if (!exists( $included_min->{$regionnum}{$ratetimenum} )) { + $included_min->{$regionnum}{$ratetimenum} = + ($rate_detail->min_included * $cust_pkg->quantity || 1); + } if ( $included_min->{$regionnum}{$ratetimenum} >= $minutes ) { $charge_sec = 0; @@ -1262,6 +1264,10 @@ my %export_names = ( 'name' => 'Number of calls, one line per service', 'invoice_header' => 'Caller,Rate,Messages,Price', }, + 'sum_duration' => { + 'name' => 'Summary, one line per service', + 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price', + }, 'sum_duration_prefix' => { 'name' => 'Summary, one line per destination prefix', 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price', @@ -1270,6 +1276,10 @@ my %export_names = ( 'name' => 'Summary, one line per usage class', 'invoice_header' => 'Caller,Class,Calls,Price', }, + 'sum_duration_accountcode' => { + 'name' => 'Summary, one line per accountcode', + 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price', + }, ); my %export_formats = (); diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 0d89ff463..cb5181d89 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -2338,6 +2338,8 @@ Removes the I<paycvv> field from the database directly. If there is an error, returns the error, otherwise returns false. +DEPRECATED. Use L</remove_cvv_from_cust_payby> instead. + =cut sub remove_cvv { @@ -4524,6 +4526,33 @@ PAYBYLOOP: } +=item remove_cvv_from_cust_payby PAYINFO + +Removes paycvv from associated cust_payby with matching PAYINFO. + +=cut + +sub remove_cvv_from_cust_payby { + my ($self,$payinfo) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + foreach my $cust_payby ( qsearch('cust_payby',{ custnum => $self->custnum }) ) { + next unless $cust_payby->payinfo eq $payinfo; # can't qsearch on payinfo + $cust_payby->paycvv(''); + my $error = $cust_payby->replace; + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; +} + =back =head1 CLASS METHODS diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index cf4b16e59..9fea1bb33 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -510,11 +510,8 @@ sub realtime_bop { $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; $content{expiration} = "$2/$1"; - my $paycvv = exists($options{'paycvv'}) - ? $options{'paycvv'} - : $self->paycvv; - $content{cvv2} = $paycvv - if length($paycvv); + $content{cvv2} = $options{'paycvv'} + if length($options{'paycvv'}); my $paystart_month = exists($options{'paystart_month'}) ? $options{'paystart_month'} @@ -764,10 +761,10 @@ sub realtime_bop { ### # compare to FS::cust_main::save_cust_payby - check both to make sure working correctly - if ( length($self->paycvv) + if ( length($options{'paycvv'}) && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save') ) { - my $error = $self->remove_cvv; + my $error = $self->remove_cvv_from_cust_payby($options{payinfo}); if ( $error ) { warn "WARNING: error removing cvv: $error\n"; } @@ -1790,11 +1787,8 @@ sub realtime_verify_bop { $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; $content{expiration} = "$2/$1"; - my $paycvv = exists($options{'paycvv'}) - ? $options{'paycvv'} - : $self->paycvv; - $content{cvv2} = $paycvv - if length($paycvv); + $content{cvv2} = $options{'paycvv'} + if length($options{'paycvv'}); my $paystart_month = exists($options{'paystart_month'}) ? $options{'paystart_month'} @@ -1918,6 +1912,8 @@ sub realtime_verify_bop { } } + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop'); + if ( $transaction->is_success() ) { $cust_pay_pending->status('authorized'); @@ -1962,11 +1958,114 @@ sub realtime_verify_bop { my $e = "Authorization successful but reversal failed, custnum #". $self->custnum. ': '. $reverse->result_code. ": ". $reverse->error_message; + $log->warning($e); warn $e; return $e; } + ### Address Verification ### + # + # Single-letter codes vary by cardtype. + # + # Erring on the side of accepting cards if avs is not available, + # only rejecting if avs occurred and there's been an explicit mismatch + # + # Charts below taken from vSecure documentation, + # shows codes for Amex/Dscv/MC/Visa + # + # ACCEPTABLE AVS RESPONSES: + # Both Address and 5-digit postal code match Y A Y Y + # Both address and 9-digit postal code match Y A X Y + # United Kingdom – Address and postal code match _ _ _ F + # International transaction – Address and postal code match _ _ _ D/M + # + # ACCEPTABLE, BUT ISSUE A WARNING: + # Ineligible transaction; or message contains a content error _ _ _ E + # System unavailable; retry R U R R + # Information unavailable U W U U + # Issuer does not support AVS S U S S + # AVS is not applicable _ _ _ S + # Incompatible formats – Not verified _ _ _ C + # Incompatible formats – Address not verified; postal code matches _ _ _ P + # International transaction – address not verified _ G _ G/I + # + # UNACCEPTABLE AVS RESPONSES: + # Only Address matches A Y A A + # Only 5-digit postal code matches Z Z Z Z + # Only 9-digit postal code matches Z Z W W + # Neither address nor postal code matches N N N N + + if (my $avscode = uc($transaction->avs_code)) { + + # map codes to accept/warn/reject + my $avs = { + 'American Express card' => { + 'A' => 'r', + 'N' => 'r', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'Y' => 'a', + 'Z' => 'r', + }, + 'Discover card' => { + 'A' => 'a', + 'G' => 'w', + 'N' => 'r', + 'U' => 'w', + 'W' => 'w', + 'Y' => 'r', + 'Z' => 'r', + }, + 'MasterCard' => { + 'A' => 'r', + 'N' => 'r', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'W' => 'r', + 'X' => 'a', + 'Y' => 'a', + 'Z' => 'r', + }, + 'VISA card' => { + 'A' => 'r', + 'C' => 'w', + 'D' => 'a', + 'E' => 'w', + 'F' => 'a', + 'G' => 'w', + 'I' => 'w', + 'M' => 'a', + 'N' => 'r', + 'P' => 'w', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'W' => 'r', + 'Y' => 'a', + 'Z' => 'r', + }, + }; + my $cardtype = cardtype($content{card_number}); + if ($avs->{$cardtype}) { + my $avsact = $avs->{$cardtype}->{$avscode}; + my $warning = ''; + if ($avsact eq 'r') { + return "AVS code verification failed, cardtype $cardtype, code $avscode"; + } elsif ($avsact eq 'w') { + $warning = "AVS did not occur, cardtype $cardtype, code $avscode"; + } elsif (!$avsact) { + $warning = "AVS code unknown, cardtype $cardtype, code $avscode"; + } # else $avsact eq 'a' + if ($warning) { + $log->warning($warning); + warn $warning; + } + } # else $cardtype avs handling not implemented + } # else !$transaction->avs_code + } else { # is not success # status is 'done' not 'declined', as in _realtime_bop_result @@ -1990,7 +2089,9 @@ sub realtime_verify_bop { $self->payinfo($transaction->card_token); my $error = $self->replace; if ( $error ) { - warn "WARNING: error storing token: $error, but proceeding anyway\n"; + my $warning = "WARNING: error storing token: $error, but proceeding anyway\n"; + $log->warning($warning); + warn $warning; } } diff --git a/FS/FS/cust_pay_pending.pm b/FS/FS/cust_pay_pending.pm index 1a5420385..dfb07b84d 100644 --- a/FS/FS/cust_pay_pending.pm +++ b/FS/FS/cust_pay_pending.pm @@ -470,6 +470,19 @@ sub _upgrade_data { #class method } +sub _upgrade_schema { + my ($class, %opts) = @_; + + # fix records where jobnum points to a nonexistent queue job + my $sql = 'UPDATE cust_pay_pending SET jobnum = NULL + WHERE NOT EXISTS ( + SELECT 1 FROM queue WHERE queue.jobnum = cust_pay_pending.jobnum + )'; + my $sth = dbh->prepare($sql) or die dbh->errstr; + $sth->execute or die $sth->errstr; + ''; +} + =back =head1 BUGS diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index fd75567e6..623a44efc 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -217,14 +217,14 @@ sub replace { $self->payinfo($new_account.'@'.$new_aba); } - # don't preserve paycvv if it was passed blank and payinfo changed - unless ( $self->payby =~ /^(CARD|DCRD)$/ - && $old->payinfo ne $self->payinfo - && $old->paymask ne $self->paymask - && $self->paycvv =~ /^\s*$/ ) - { - if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) { + # only unmask paycvv if payinfo stayed the same + if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) { + if ( $old->payinfo eq $self->payinfo + && $old->paymask eq $self->paymask + ) { $self->paycvv($old->paycvv); + } else { + $self->paycvv(''); } } diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 1cc82357e..d15eb89ac 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -2537,6 +2537,16 @@ sub change_later { return "start_date $date is in the past"; } + # If the user entered a new location, set it up now. + if ( $opt->{'cust_location'} ) { + $error = $opt->{'cust_location'}->find_or_insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "creating location record: $error"; + } + $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; + } + if ( $self->change_to_pkgnum ) { my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum); my $new_pkgpart = $opt->{'pkgpart'} diff --git a/FS/FS/cust_svc.pm b/FS/FS/cust_svc.pm index 9d9ecdd50..3f7348321 100644 --- a/FS/FS/cust_svc.pm +++ b/FS/FS/cust_svc.pm @@ -823,13 +823,12 @@ sub seconds_since { 'internal session db deprecated'; }; =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END -See L<FS::svc_acct/seconds_since_sqlradacct>. Equivalent to -$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless -for records where B<svcdb> is not "svc_acct". +Equivalent to $cust_svc->svc_x->seconds_since_sqlradacct, but +more efficient. Meaningless for records where B<svcdb> is not +svc_acct or svc_broadband. =cut -#note: implementation here, POD in FS::svc_acct sub seconds_since_sqlradacct { my($self, $start, $end) = @_; @@ -968,12 +967,11 @@ sub seconds_since_sqlradacct { =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE See L<FS::svc_acct/attribute_since_sqlradacct>. Equivalent to -$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless -for records where B<svcdb> is not "svc_acct". +$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. +Meaningless for records where B<svcdb> is not svc_acct or svc_broadband. =cut -#note: implementation here, POD in FS::svc_acct #(false laziness w/seconds_since_sqlradacct above) sub attribute_since_sqlradacct { my($self, $start, $end, $attrib) = @_; diff --git a/FS/FS/detail_format/sum_duration_accountcode.pm b/FS/FS/detail_format/sum_duration_accountcode.pm new file mode 100644 index 000000000..d181d474c --- /dev/null +++ b/FS/FS/detail_format/sum_duration_accountcode.pm @@ -0,0 +1,69 @@ +package FS::detail_format::sum_duration_accountcode; + +use strict; +use vars qw( $DEBUG ); +use base qw(FS::detail_format); + +$DEBUG = 0; + +my $me = '[sum_duration_accountcode]'; + +sub name { 'Summary, one line per accountcode' }; + +sub header_detail { + 'Account code,Calls,Duration,Price'; +} + +sub append { + my $self = shift; + my $codes = ($self->{codes} ||= {}); + my $acctids = ($self->{acctids} ||= []); + foreach my $cdr (@_) { + my $accountcode = $cdr->accountcode || 'other'; + + my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr; + my $subtotal = $codes->{$accountcode} + ||= { count => 0, duration => 0, amount => 0.0 }; + $subtotal->{count}++; + $subtotal->{duration} += $object->rated_seconds; + $subtotal->{amount} += $object->rated_price + if $object->freesidestatus ne 'no-charge'; + + push @$acctids, $cdr->acctid; + } +} + +sub finish { + my $self = shift; + my $codes = $self->{codes}; + foreach my $accountcode (sort { $a cmp $b } keys %$codes) { + + warn "processing $accountcode\n" if $DEBUG; + + my $subtotal = $codes->{$accountcode}; + + $self->csv->combine( + $accountcode, + $subtotal->{count}, + sprintf('%.01f min', $subtotal->{duration}/60), + $self->money_char . sprintf('%.02f', $subtotal->{amount}) + ); + + warn "adding detail: ".$self->csv->string."\n" if $DEBUG; + + push @{ $self->{buffer} }, FS::cust_bill_pkg_detail->new({ + amount => $subtotal->{amount}, + format => 'C', + classnum => '', #ignored in this format + duration => $subtotal->{duration}, + phonenum => '', # not divided up per service + accountcode => $accountcode, + startdate => '', + regionname => '', + detail => $self->csv->string, + acctid => $self->{acctids}, + }); + } #foreach $accountcode +} + +1; diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 9dba5824c..ab1b0c388 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -9,6 +9,7 @@ my @contexts = ( qw( bill_and_collect FS::cust_main::Billing::bill_and_collect FS::cust_main::Billing::bill + FS::cust_main::Billing_Realtime::realtime_verify_bop FS::pay_batch::import_from_gateway FS::Misc::Geo::standardize_uscensus Cron::bill diff --git a/FS/FS/part_pkg/prorate_Mixin.pm b/FS/FS/part_pkg/prorate_Mixin.pm index 26fdc3558..beae6d805 100644 --- a/FS/FS/part_pkg/prorate_Mixin.pm +++ b/FS/FS/part_pkg/prorate_Mixin.pm @@ -191,22 +191,35 @@ sub prorate_setup { my $self = shift; my ($cust_pkg, $sdate) = @_; my @cutoff_days = $self->cutoff_day($cust_pkg); - if ( ! $cust_pkg->bill - and $self->option('prorate_defer_bill',1) - and @cutoff_days - ) { - my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days); - # If today is the cutoff day, set the next bill and setup both to - # midnight today, so that the customer will be billed normally for a - # month starting today. - if ( $mnow - $mstart < 86400 ) { - $cust_pkg->setup($mstart); - $cust_pkg->bill($mstart); + if ( @cutoff_days and $self->option('prorate_defer_bill', 1) ) { + if ( $cust_pkg->setup ) { + # Setup date is already set. Then we're being called indirectly via calc_prorate + # to calculate the deferred setup fee. Allow that to happen normally. + return 0; + } else { + # We're going to set the setup date (so that the deferred billing knows when + # the package started) and suppress charging the setup fee. + if ( $cust_pkg->bill ) { + # For some reason (probably user override), the bill date has been set even + # though the package isn't billing yet. Start billing as though that was the + # start date. + $sdate = $cust_pkg->bill; + $cust_pkg->setup($cust_pkg->bill); + } + # Now figure the start and end of the period that contains the start date. + my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days); + # If today is the cutoff day, set the next bill and setup both to + # midnight today, so that the customer will be billed normally for a + # month starting today. + if ( $mnow - $mstart < 86400 ) { + $cust_pkg->setup($mstart); + $cust_pkg->bill($mstart); + } + else { + $cust_pkg->bill($mend); + } + return 1; } - else { - $cust_pkg->bill($mend); - } - return 1; } return 0; } diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm index be2d15b23..24c4cf041 100644 --- a/FS/FS/part_pkg/voip_cdr.pm +++ b/FS/FS/part_pkg/voip_cdr.pm @@ -401,8 +401,10 @@ sub calc_usage { my $included_min = $self->option('min_included', 1) || 0; #single price rating #or region group + $included_min *= ($cust_pkg->quantity || 1); my $included_calls = $self->option('calls_included', 1) || 0; + $included_calls *= ($cust_pkg->quantity || 1); my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum'; my $rating_method = $self->option('rating_method') || 'prefix'; @@ -664,7 +666,8 @@ sub reset_usage { FS::cust_pkg_usage->new({ 'pkgnum' => $cust_pkg->pkgnum, 'pkgusagepart' => $part, - 'minutes' => $part_pkg_usage->minutes, + 'minutes' => $part_pkg_usage->minutes * + ($cust_pkg->quantity || 1), }); foreach my $cdr_usage ( qsearch('cdr_cust_pkg_usage', {'cdrusagenum' => $usage->cdrusagenum}) diff --git a/FS/FS/part_pkg/voip_inbound.pm b/FS/FS/part_pkg/voip_inbound.pm index 052bb7f9f..81f276500 100644 --- a/FS/FS/part_pkg/voip_inbound.pm +++ b/FS/FS/part_pkg/voip_inbound.pm @@ -214,6 +214,7 @@ sub calc_usage { # my $downstream_cdr = ''; my $included_min = $self->option('min_included', 1) || 0; + $included_min *= ($cust_pkg->quantity || 1); my $use_duration = $self->option('use_duration'); my $output_format = $self->option('output_format', 1) || 'default'; diff --git a/FS/FS/part_pkg/voip_sqlradacct.pm b/FS/FS/part_pkg/voip_sqlradacct.pm index a205f9fe6..299d5c1d0 100644 --- a/FS/FS/part_pkg/voip_sqlradacct.pm +++ b/FS/FS/part_pkg/voip_sqlradacct.pm @@ -131,7 +131,8 @@ sub calc_recur { # find the price and add detail to the invoice ### - $included_min{$regionnum} = $rate_detail->min_included + $included_min{$regionnum} = + ($rate_detail->min_included * $cust_pkg->quantity || 1) unless exists $included_min{$regionnum}; my $granularity = $rate_detail->sec_granularity; diff --git a/FS/FS/part_pkg/voip_tiered.pm b/FS/FS/part_pkg/voip_tiered.pm index d8d74c13f..0ad0ff6bf 100644 --- a/FS/FS/part_pkg/voip_tiered.pm +++ b/FS/FS/part_pkg/voip_tiered.pm @@ -81,6 +81,7 @@ sub calc_usage { && ( $last_bill eq '' || $last_bill == 0 ); my $included_min = $self->option('min_included', 1) || 0; + $included_min *= ($cust_pkg->quantity || 1); my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum'; my $cdr_inout = ($cdr_svc_method eq 'svc_phone.phonenum') && $self->option('cdr_inout',1) diff --git a/FS/FS/password_history.pm b/FS/FS/password_history.pm index dd527b980..a34f6169b 100644 --- a/FS/FS/password_history.pm +++ b/FS/FS/password_history.pm @@ -160,6 +160,29 @@ sub password_equals { } +sub _upgrade_schema { + # clean up history records where linked_acct has gone away + my @where; + for my $fk ( grep /__/, __PACKAGE__->dbdef_table->columns ) { + my ($table, $key) = split(/__/, $fk); + push @where, " + ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )"; + } + my @recs = qsearch({ + 'table' => 'password_history', + 'extra_sql' => ' WHERE ' . join(' AND ', @where), + }); + my $error; + if (@recs) { + warn "Removing unattached password_history records (".scalar(@recs).").\n"; + foreach my $password_history (@recs) { + $error = $password_history->delete; + die $error if $error; + } + } + ''; +} + =back =head1 BUGS diff --git a/FS/bin/freeside-ipifony-download b/FS/bin/freeside-ipifony-download index ee1f4bdfe..10faa7483 100644 --- a/FS/bin/freeside-ipifony-download +++ b/FS/bin/freeside-ipifony-download @@ -13,7 +13,7 @@ use File::Copy qw(copy); use Text::CSV; my %opt; -getopts('vqa:P:C:e:', \%opt); +getopts('vqNa: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. @@ -32,6 +32,7 @@ sub HELP_MESSAGE { ' freeside-ipifony-download [ -v ] [ -q ] + [ -N ] [ -a archivedir ] [ -P port ] [ -C category ] @@ -192,7 +193,8 @@ FILE: foreach my $filename (@$files) { if ( $next_bill_date ) { my ($bill_month, $bill_year) = (localtime($next_bill_date))[4, 5]; my ($this_month, $this_year) = (localtime(time))[4, 5]; - if ( $this_month == $bill_month and $this_year == $bill_year ) { + if ( $opt{N} or + $this_month == $bill_month and $this_year == $bill_year ) { $cust_main->set('charge_date', $next_bill_date); } } @@ -296,6 +298,7 @@ freeside-ipifony-download - Download and import invoice items from IPifony. freeside-ipifony-download [ -v ] [ -q ] + [ -N ] [ -a archivedir ] [ -P port ] [ -C category ] @@ -312,12 +315,19 @@ have an authorization key to connect as that user. I<hostname>: the SFTP server. +I<path>: the path on the server to the working directory. The working +directory is the one containing the "ready/" and "done/" subdirectories. + =head1 OPTIONAL PARAMETERS -v: Be verbose. -q: Include the quantity and unit price in the charge description. +-N: Always bill the charges on the customer's next bill date, if they have +one. Otherwise, charges will be billed on the next bill date only if it's +within the current calendar month. + -a I<archivedir>: Save a copy of the downloaded file to I<archivedir>. -P I<port>: Connect to that TCP port. diff --git a/FS/t/suite/06-prorate_defer_bill.t b/FS/t/suite/06-prorate_defer_bill.t new file mode 100755 index 000000000..e14b8ec21 --- /dev/null +++ b/FS/t/suite/06-prorate_defer_bill.t @@ -0,0 +1,92 @@ +#!/usr/bin/perl + +=head2 DESCRIPTION + +Tests the prorate_defer_bill behavior when a package is started on the cutoff day, +and when it's started later in the month. + +Correct: The package started on the cutoff day should be charged a setup fee and a +full period. The package started later in the month should be charged a setup fee, +a full period, and the partial period. + +=cut + +use strict; +use Test::More tests => 11; +use FS::Test; +use Date::Parse 'str2time'; +use Date::Format 'time2str'; +use Test::MockTime qw(set_fixed_time); +use FS::cust_main; +use FS::cust_pkg; +use FS::Conf; +my $FS= FS::Test->new; + +my $error; + +my $old_part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 2 }); +my $part_pkg = $old_part_pkg->clone; +BAIL_OUT("existing pkgpart 2 is not a prorated monthly package") + unless $part_pkg->freq eq '1' and $part_pkg->plan eq 'prorate'; +$error = $part_pkg->insert( + options => { $old_part_pkg->options, + 'prorate_defer_bill' => 1, + 'cutoff_day' => 1, + 'setup_fee' => 100, + 'recur_fee' => 30, + } +); +BAIL_OUT("can't configure package: $error") if $error; + +my $cust = $FS->new_customer('Prorate defer'); +$error = $cust->insert; +BAIL_OUT("can't create test customer: $error") if $error; + +my @pkgs; +foreach my $start_day (1, 11) { + diag("prorate package starting on day $start_day"); + # Create and bill the first package. + my $date = str2time("2016-04-$start_day"); + set_fixed_time($date); + my $pkg = FS::cust_pkg->new({ pkgpart => $part_pkg->pkgpart }); + $error = $cust->order_pkg({ 'cust_pkg' => $pkg }); + BAIL_OUT("can't order package: $error") if $error; + + # bill the customer on the order date + $error = $cust->bill_and_collect; + $pkg = $pkg->replace_old; + push @pkgs, $pkg; + my ($cust_bill_pkg) = $pkg->cust_bill_pkg; + if ( $start_day == 1 ) { + # then it should bill immediately + ok($cust_bill_pkg, "package was billed") or next; + ok($cust_bill_pkg->setup == 100, "setup fee was charged"); + ok($cust_bill_pkg->recur == 30, "one month was charged"); + } elsif ( $start_day == 11 ) { + # then not + ok(!$cust_bill_pkg, "package billing was deferred"); + ok($pkg->setup == $date, "package setup date was set"); + } +} +diag("first of month billing..."); +my $date = str2time('2016-05-01'); +set_fixed_time($date); +my @bill; +$error = $cust->bill_and_collect(return_bill => \@bill); +# examine the invoice... +my $cust_bill = $bill[0] or BAIL_OUT("neither package was billed"); +for my $pkg ($pkgs[0]) { + diag("package started day 1:"); + my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg; + ok($cust_bill_pkg, "was billed") or next; + ok($cust_bill_pkg->setup == 0, "no setup fee was charged"); + ok($cust_bill_pkg->recur == 30, "one month was charged"); +} +for my $pkg ($pkgs[1]) { + diag("package started day 11:"); + my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg; + ok($cust_bill_pkg, "was billed") or next; + ok($cust_bill_pkg->setup == 100, "setup fee was charged"); + ok($cust_bill_pkg->recur == 50, "twenty days + one month was charged"); +} + diff --git a/FS/t/suite/07-pkg_change_location.t b/FS/t/suite/07-pkg_change_location.t new file mode 100755 index 000000000..6744f78ef --- /dev/null +++ b/FS/t/suite/07-pkg_change_location.t @@ -0,0 +1,82 @@ +#!/usr/bin/perl + +=head2 DESCRIPTION + +Test scheduling a package location change through the UI, then billing +on the day of the scheduled change. + +=cut + +use Test::More tests => 6; +use FS::Test; +use Date::Parse 'str2time'; +use Date::Format 'time2str'; +use Test::MockTime qw(set_fixed_time); +use FS::cust_pkg; +my $FS = FS::Test->new; +my $error; + +# set up a customer with an active package +my $cust = $FS->new_customer('Future location change'); +$error = $cust->insert; +my $pkg = FS::cust_pkg->new({pkgpart => 2}); +$error ||= $cust->order_pkg({ cust_pkg => $pkg }); +my $date = str2time('2016-04-01'); +set_fixed_time($date); +$error ||= $cust->bill_and_collect; +BAIL_OUT($error) if $error; + +# get the form +my %args = ( pkgnum => $pkg->pkgnum, + pkgpart => $pkg->pkgpart, + locationnum => -1); +$FS->post('/misc/change_pkg.cgi', %args); +my $form = $FS->form('OrderPkgForm'); + +# Schedule the package change two days from now. +$date += 86400*2; +my $date_str = time2str('%x', $date); + +my %params = ( + start_date => $date_str, + delay => 1, + address1 => int(rand(1000)) . ' Changed Street', + city => 'New City', + state => 'CA', + zip => '90001', + country => 'US', +); + +diag "requesting location change to $params{address1}"; + +foreach (keys %params) { + $form->value($_, $params{$_}); +} +$FS->post($form); +ok( $FS->error eq '' , 'form posted' ); +if ( ok( $FS->page =~ m[location.reload], 'location change accepted' )) { + #nothing +} else { + $FS->post($FS->redirect); + BAIL_OUT( $FS->error); +} +# check that the package change is set +$pkg = $pkg->replace_old; +my $new_pkgnum = $pkg->change_to_pkgnum; +ok( $new_pkgnum, 'package change is scheduled' ); + +# run it and check that the package change happened +diag("billing customer on $date_str"); +set_fixed_time($date); +my $error = $cust->bill_and_collect; +BAIL_OUT($error) if $error; + +$pkg = $pkg->replace_old; +ok($pkg->get('cancel'), "old package is canceled"); +my $new_pkg = $FS->qsearchs('cust_pkg', { pkgnum => $new_pkgnum }); +ok($new_pkg->setup, "new package is active"); +ok($new_pkg->cust_location->address1 eq $params{'address1'}, "new location is correct") + or diag $new_pkg->cust_location->address1; + +1; + |
