From: Ivan Kohler Date: Sat, 4 Jun 2016 00:23:02 +0000 (-0700) Subject: Merge branch 'master' of git.freeside.biz:/home/git/freeside X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=d22baa4e71bfa9e153c1fe1152ff4c748f1d935c;hp=a60ce94f914fb7380546d19713fece0ed208bdc6 Merge branch 'master' of git.freeside.biz:/home/git/freeside --- 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 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 field from the database directly. If there is an error, returns the error, otherwise returns false. +DEPRECATED. Use L 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. Equivalent to -$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless -for records where B is not "svc_acct". +Equivalent to $cust_svc->svc_x->seconds_since_sqlradacct, but +more efficient. Meaningless for records where B 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. Equivalent to -$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless -for records where B is not "svc_acct". +$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. +Meaningless for records where B 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: the SFTP server. +I: 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: Save a copy of the downloaded file to I. -P I: 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; + diff --git a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi index 1054e6a00..2407361f0 100755 --- a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi +++ b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi @@ -998,7 +998,7 @@ sub delete_svc { sub view_usage { my $res = list_svcs( 'session_id' => $session_id, - 'svcdb' => [ 'svc_acct', 'svc_phone', 'svc_port', 'svc_pbx' ], + 'svcdb' => [ 'svc_acct', 'svc_broadband', 'svc_phone', 'svc_port', 'svc_pbx' ], 'ncancelled' => 1, ); if ($res->{hide_usage}) { diff --git a/fs_selfservice/FS-SelfService/cgi/view_usage.html b/fs_selfservice/FS-SelfService/cgi/view_usage.html index 4099a57f5..07ccfedf1 100644 --- a/fs_selfservice/FS-SelfService/cgi/view_usage.html +++ b/fs_selfservice/FS-SelfService/cgi/view_usage.html @@ -1,21 +1,27 @@ <%= $url = "$selfurl?action="; %by_pkg_label = (); # not used yet, but I'm sure it will be... - @svc_acct = (); @svc_phone = (); @svc_port = (); @svc_pbx = (); - - foreach (@svcs) { - $by_pkg_label{ $_->{pkg_label} } ||= []; - push @{ $by_pkg_label{ $_->{pkg_label} } }, $_; - if ( $_->{svcdb} eq 'svc_acct' ) { - push @svc_acct, $_; - } elsif ( $_->{svcdb} eq 'svc_phone' ) { - push @svc_phone, $_; - } elsif ( $_->{svcdb} eq 'svc_port' ) { - push @svc_port, $_; - } elsif ( $_->{svcdb} eq 'svc_pbx' ) { - push @svc_pbx, $_; + @bytes_svcs = (); # contains svc_acct and svc_broadband + @bytes_cols = qw(seconds_used seconds upbytes_used upbytes downbytes_used downbytes totalbytes_used totalbytes); + %bytes_show = map { $_ => 0 } @bytes_cols; + + foreach my $svc (@svcs) { + $by_pkg_label{ $svc->{pkg_label} } ||= []; + push @{ $by_pkg_label{ $svc->{pkg_label} } }, $svc; + + if (( $svc->{svcdb} eq 'svc_acct' ) || ( $svc->{svcdb} eq 'svc_broadband' )) { + foreach my $field (@bytes_cols) { + $bytes_show{$field} = 1 if length($svc->{$field}) or (($field !~ /_used$/) && $svc->{'recharge_'.$field}); + } + push @bytes_svcs, $svc; + } elsif ( $svc->{svcdb} eq 'svc_phone' ) { + push @svc_phone, $svc; + } elsif ( $svc->{svcdb} eq 'svc_port' ) { + push @svc_port, $svc; + } elsif ( $svc->{svcdb} eq 'svc_pbx' ) { + push @svc_pbx, $svc; } } ''; @@ -26,56 +32,52 @@ $OUT .= qq!$error

!; } ''; %> -<%= if ( @svc_acct ) { - $OUT.= ' - - - - - - - '; - } else { - $OUT .= ''; +<%= if ( @bytes_svcs ) { + $OUT .= '
AccountTime remainingUpload remainingDownload remainingTotal remaining
'; + $OUT .= ''; + $OUT .= '' if $bytes_show{'seconds_used'}; + $OUT .= '' if $bytes_show{'seconds'}; + $OUT .= '' if $bytes_show{'upbytes_used'}; + $OUT .= '' if $bytes_show{'upbytes'}; + $OUT .= '' if $bytes_show{'downbytes_used'}; + $OUT .= '' if $bytes_show{'downbytes'}; + $OUT .= '' if $bytes_show{'totalbytes_used'}; + $OUT .= '' if $bytes_show{'totalbytes'}; + $OUT .= ''; } %> -<%= foreach my $svc ( @svc_acct ) { - my $link = "${url}view_usage_details;". - "svcnum=$svc->{'svcnum'};beginning=0;ending=0"; +<%= foreach my $svc ( @bytes_svcs ) { + my $link = "${url}view_usage_details;". + "svcnum=$svc->{'svcnum'};beginning=0;ending=0"; my $username = $svc->{'value'}; $username =~ s/@.*?$//g if $view_usage_nodomain; - $OUT .= ''; + $OUT .= ''; + $OUT .= ''; + foreach my $field (@bytes_cols) { + $OUT .= '' if $bytes_show{$field}; + } if ( $svc->{'recharge_amount'} ) { my $link = "${url}process_order_recharge;". "svcnum=$svc->{'svcnum'}"; - $OUT .= ''; + $OUT .= ''; + foreach my $field ( qw(seconds upbytes downbytes totalbytes) ) { + $OUT .= '' if $bytes_show{$field.'_used'}; + if ($bytes_show{$field}) { + $OUT .= ''; + } + } + $OUT .= ''; } } %> -<%= scalar(@svc_acct) ? '
AccountTime usedTime remainingUpload usedUpload remainingDownload usedDownload remainingTotal usedTotal remaining
'; - $OUT .= qq!!. $svc->{'label'}. ': '. $username .''; - $OUT .= ''; - $OUT .= $svc->{'seconds'}; - $OUT .= ''; - $OUT .= $svc->{'upbytes'}; - $OUT .= ''; - $OUT .= $svc->{'downbytes'}; - $OUT .= ''; - $OUT .= $svc->{'totalbytes'}; - $OUT .= '
' . qq!!. $svc->{'label'}. ': '. $username .'' . $svc->{$field} . '
'; + $OUT .= '
'; $OUT .= qq!!.'Recharge for $'; $OUT .= $svc->{'recharge_amount'} . ' with'; - $OUT .= ''; - $OUT .= $svc->{'recharge_seconds'} if $svc->{'recharge_seconds'}; - $OUT .= ''; - $OUT .= $svc->{'recharge_upbytes'} if $svc->{'recharge_upbytes'}; - $OUT .= ''; - $OUT .= $svc->{'recharge_downbytes'} if $svc->{'recharge_downbytes'}; - $OUT .= ''; - $OUT .= $svc->{'recharge_totalbytes'} if $svc->{'recharge_totalbytes'}; - $OUT .= '
'; + $OUT .= $svc->{'recharge_'.$field} if $svc->{'recharge_'.$field}; + $OUT .= '


' : '' %> +<%= scalar(@bytes_svcs) ? '

' : '' %> <%= if ( @svc_phone or @svc_pbx ) { %any = (); diff --git a/httemplate/edit/process/quotation_pkg_detail.html b/httemplate/edit/process/quotation_pkg_detail.html index 9e4ac3222..b836baebc 100644 --- a/httemplate/edit/process/quotation_pkg_detail.html +++ b/httemplate/edit/process/quotation_pkg_detail.html @@ -26,8 +26,16 @@ my $quotation_pkg = qsearchs({ 'LEFT JOIN cust_main USING ( custnum )', 'hashref' => { 'quotationpkgnum' => $pkgnum }, 'extra_sql' => ' AND '. $curuser->agentnums_sql, +}) +|| qsearchs({ + 'table' => 'quotation_pkg', + 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'. + 'LEFT JOIN prospect_main USING ( prospectnum )', + 'hashref' => { 'quotationpkgnum' => $pkgnum }, + 'extra_sql' => ' AND '. $curuser->agentnums_sql, }); + my @orig_details = $quotation_pkg->details(); my $action = 'Quotation details'. diff --git a/httemplate/edit/quotation_pkg_detail.html b/httemplate/edit/quotation_pkg_detail.html index 036bffdde..9aa50ec38 100644 --- a/httemplate/edit/quotation_pkg_detail.html +++ b/httemplate/edit/quotation_pkg_detail.html @@ -61,6 +61,13 @@ my $quotation_pkg = qsearchs({ 'LEFT JOIN cust_main USING ( custnum )', 'hashref' => { 'quotationpkgnum' => $pkgnum }, 'extra_sql' => ' AND '. $curuser->agentnums_sql, +}) +|| qsearchs({ + 'table' => 'quotation_pkg', + 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'. + 'LEFT JOIN prospect_main USING ( prospectnum )', + 'hashref' => { 'quotationpkgnum' => $pkgnum }, + 'extra_sql' => ' AND '. $curuser->agentnums_sql, }); my $part_pkg = $quotation_pkg->part_pkg; diff --git a/httemplate/elements/cust_payby.html b/httemplate/elements/cust_payby.html index c7d4549df..60e6eb8b1 100644 --- a/httemplate/elements/cust_payby.html +++ b/httemplate/elements/cust_payby.html @@ -68,7 +68,7 @@ ID = "<%$id%>_paycvv" SIZE = 2 MAXLENGTH = 4 - VALUE = "<% scalar($cgi->param($name.'_paycvv')) %>" + VALUE = "<% scalar($cgi->param($name.'_paycvv')) || ('*' x length($cust_payby->paycvv)) %>" onChange = "<% $onchange %>" >
<% mt('CVV2') |h %> (<% mt('help') |h %>) diff --git a/httemplate/elements/header-full.html b/httemplate/elements/header-full.html index 699f82c53..db38eafba 100644 --- a/httemplate/elements/header-full.html +++ b/httemplate/elements/header-full.html @@ -67,6 +67,9 @@ Example: <% $company_name || 'ExampleCo' %> + + <& notify-tickets.html &> + Logged in as <% $FS::CurrentUser::CurrentUser->username |h %>  logout
Preferences % if ( $conf->config("ticket_system") % && FS::TicketSystem->access_right(\%session, 'ModifySelf') ) { diff --git a/httemplate/elements/notify-tickets.html b/httemplate/elements/notify-tickets.html new file mode 100644 index 000000000..faf998e2b --- /dev/null +++ b/httemplate/elements/notify-tickets.html @@ -0,0 +1,36 @@ +% if ($enabled) { + +
+% if ( $UnrepliedTickets->Count > 0 ) { + +
+ <% emt('New activity on [quant,_1,ticket]', $UnrepliedTickets->Count) %> +
+% } else { + <% emt('No new activity on tickets') %> +% } +
+% } +<%init> +use Class::Load 'load_class'; + +my $enabled = $FS::TicketSystem::system eq 'RT_Internal'; +my $UnrepliedTickets; +if ($enabled) { + my $class = 'RT::Search::UnrepliedTickets'; + load_class($class); + my $session = FS::TicketSystem->session; + my $CurrentUser = $session->{CurrentUser}; + $UnrepliedTickets = RT::Tickets->new($CurrentUser); + my $search = $class->new(TicketsObj => $UnrepliedTickets); + $search->Prepare; +} + diff --git a/httemplate/elements/tr-select-cust-part_pkg.html b/httemplate/elements/tr-select-cust-part_pkg.html index 6244b6cb7..f4af405fc 100644 --- a/httemplate/elements/tr-select-cust-part_pkg.html +++ b/httemplate/elements/tr-select-cust-part_pkg.html @@ -86,7 +86,7 @@ % } else { # so that the rest of the page works correctly - + % } diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index d232fe729..7768f921f 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -86,7 +86,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { $payinfo = $cust_payby->payinfo; $paymask = $cust_payby->paymask; - $paycvv = ''; + $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it ( $month, $year ) = $cust_payby->paydate_mon_year; $payname = $cust_payby->payname; diff --git a/rt/etc/initialdata b/rt/etc/initialdata index 96255b5ed..825e6506f 100644 --- a/rt/etc/initialdata +++ b/rt/etc/initialdata @@ -106,9 +106,6 @@ { Name => 'Open Tickets', # loc Description => 'Open tickets on correspondence', # loc ExecModule => 'AutoOpen' }, - { Name => 'Open Inactive Tickets', # loc - Description => 'Open inactive tickets', # loc - ExecModule => 'AutoOpenInactive' }, { Name => 'Extract Subject Tag', # loc Description => 'Extract tags from a Transaction\'s subject and add them to the Ticket\'s subject.', # loc ExecModule => 'ExtractSubjectTag' }, @@ -801,9 +798,9 @@ Hour: { $SubscriptionObj->SubValue('Hour') } # ScripCondition => 'On Correspond', # ScripAction => 'Notify Requestors And Ccs', # Template => 'Correspondence in HTML' }, - { Description => 'On Correspond Open Inactive Tickets', + { Description => 'On Correspond Open Tickets', ScripCondition => 'On Correspond', - ScripAction => 'Open Inactive Tickets', + ScripAction => 'Open Tickets', Template => 'Blank' }, { Description => 'On Create Autoreply To Requestors', ScripCondition => 'On Create', @@ -947,7 +944,17 @@ Hour: { $SubscriptionObj->SubValue('Hour') } 'on correspond' => { 'notify requestors and ccs' => { 'correspondence' => 1 }, 'notify other recipients' => { 'correspondence' => 1 }, - } + # RT 4.2 + # superseded by "notify owner and adminccs" + 'notify adminccs' => { 'admin correspondence' => 1 }, + # the new way, but doesn't work right vs. "open tickets" + 'open inactive tickets' => { 'blank' => 1 }, + }, + 'on create' => { + # RT 4.2 + # superseded by "notify owner and adminccs" + 'notify adminccs' => { 'transaction' => 1 }, + }, ); # -*- perl -*- diff --git a/rt/lib/RT/Search/UnrepliedTickets.pm b/rt/lib/RT/Search/UnrepliedTickets.pm new file mode 100644 index 000000000..a99690156 --- /dev/null +++ b/rt/lib/RT/Search/UnrepliedTickets.pm @@ -0,0 +1,62 @@ +=head1 NAME + + RT::Search::UnrepliedTickets + +=head1 SYNOPSIS + +=head1 DESCRIPTION + +Find all unresolved tickets owned by the current user where the last correspondence +from a requestor (or ticket creation) is more recent than the last +correspondence from a non-requestor (if there is any). + +=head1 METHODS + +=cut + +package RT::Search::UnrepliedTickets; + +use strict; +use warnings; +use base qw(RT::Search); + + +sub Describe { + my $self = shift; + return ($self->loc("Tickets awaiting a reply")); +} + +sub Prepare { + my $self = shift; + + my $TicketsObj = $self->TicketsObj; + $TicketsObj->Limit( + FIELD => 'Owner', + VALUE => $TicketsObj->CurrentUser->id + ); + $TicketsObj->Limit( + FIELD => 'Status', + OPERATOR => '!=', + VALUE => 'resolved' + ); + my $txn_alias = $TicketsObj->JoinTransactions; + $TicketsObj->Limit( + ALIAS => $txn_alias, + FIELD => 'Created', + OPERATOR => '>', + VALUE => 'COALESCE(main.Told,\'1970-01-01\')', + QUOTEVALUE => 0, + ); + $TicketsObj->Limit( + ALIAS => $txn_alias, + FIELD => 'Type', + OPERATOR => 'IN', + VALUE => [ 'Correspond', 'Create' ], + ); + + return(1); +} + +RT::Base->_ImportOverlays(); + +1; diff --git a/rt/share/html/Search/UnrepliedTickets.html b/rt/share/html/Search/UnrepliedTickets.html new file mode 100755 index 000000000..37f94e0b2 --- /dev/null +++ b/rt/share/html/Search/UnrepliedTickets.html @@ -0,0 +1,156 @@ +%# false laziness with Results.html; basically this is the same thing but with +%# a hardcoded RT::Tickets object instead of a Query param + +<& /Elements/Header, Title => $title, + Refresh => $refresh, + LinkRel => \%link_rel &> + +% $m->callback( ARGSRef => \%ARGS, Format => \$Format, CallbackName => 'BeforeResults' ); + +<& /Elements/CollectionList, + Class => 'RT::Tickets', + Collection => $session{tickets}, + TotalFound => $ticketcount, + AllowSorting => 1, + OrderBy => $OrderBy, + Order => $Order, + Rows => $Rows, + Page => $Page, + Format => $Format, + BaseURL => $BaseURL, + SavedSearchId => $ARGS{'SavedSearchId'}, + SavedChartSearchId => $ARGS{'SavedChartSearchId'}, + PassArguments => [qw(Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId)], +&> +% $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' ); + +% my %hiddens = (Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId ); +
+
+% foreach my $key (keys(%hiddens)) { + +% } +<& /Elements/Refresh, Name => 'TicketsRefreshInterval', Default => $session{'tickets_refresh_interval'}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &> + +
+
+<%INIT> +$m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' ); + +# Read from user preferences +my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {}; + +# These variables are what define a search_hash; this is also +# where we give sane defaults. +$Format ||= $prefs->{'Format'} || RT->Config->Get('DefaultSearchResultFormat'); +$Order ||= $prefs->{'Order'} || RT->Config->Get('DefaultSearchResultOrder'); +$OrderBy ||= $prefs->{'OrderBy'} || RT->Config->Get('DefaultSearchResultOrderBy'); + +# In this case the search UI isn't available, so trust the defaults. + +# Some forms pass in "RowsPerPage" rather than "Rows" +# We call it RowsPerPage everywhere else. + +if ( defined $prefs->{'RowsPerPage'} ) { + $Rows = $prefs->{'RowsPerPage'}; +} else { + $Rows = 50; +} +$Page = 1 unless $Page && $Page > 0; + +use RT::Search::UnrepliedTickets; + +$session{'i'}++; +$session{'tickets'} = RT::Tickets->new($session{'CurrentUser'}) ; +my $search = RT::Search::UnrepliedTickets->new( TicketsObj => $session{'tickets'} ); +$search->Prepare; + +if ($OrderBy =~ /\|/) { + # Multiple Sorts + my @OrderBy = split /\|/,$OrderBy; + my @Order = split /\|/,$Order; + $session{'tickets'}->OrderByCols( + map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0 + .. $#OrderBy ) );; +} else { + $session{'tickets'}->OrderBy(FIELD => $OrderBy, ORDER => $Order); +} +$session{'tickets'}->RowsPerPage( $Rows ) if $Rows; +$session{'tickets'}->GotoPage( $Page - 1 ); + +# use this to set a CSRF token applying to the search, so that the user can come +# back to this page without triggering a referrer check +$session{'CurrentSearchHash'} = { + Format => $Format, + Page => $Page, + Order => $Order, + OrderBy => $OrderBy, + RowsPerPage => $Rows +}; + + +my $ticketcount = $session{tickets}->CountAll(); +my $title = loc('New activity on [quant,_1,ticket,tickets]', $ticketcount); + +# pass this through on pagination links +my $QueryString = "?".$m->comp('/Elements/QueryString', + Format => $Format, + Rows => $Rows, + OrderBy => $OrderBy, + Order => $Order, + Page => $Page); + +if ($ARGS{'TicketsRefreshInterval'}) { + $session{'tickets_refresh_interval'} = $ARGS{'TicketsRefreshInterval'}; +} + +my $refresh = $session{'tickets_refresh_interval'} + || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} ); + +# Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh +if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) { + my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentSearchHash'} ); + $m->notes->{RefreshURL} = RT->Config->Get('WebURL') + . "Search/UnrepliedTickets.html?CSRF_Token=" + . $token; +} + +my %link_rel; +my $genpage = sub { + return $m->comp( + '/Elements/QueryString', + Format => $Format, + Rows => $Rows, + OrderBy => $OrderBy, + Order => $Order, + Page => shift(@_), + ); +}; + +if ( RT->Config->Get('SearchResultsAutoRedirect') && $ticketcount == 1 && + $session{tickets}->First ) { +# $ticketcount is not always precise unless $UseSQLForACLChecks is set to true, +# check $session{tickets}->First here is to make sure the ticket is there. + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') + ."Ticket/Display.html?id=". $session{tickets}->First->id ); +} + +my $BaseURL = RT->Config->Get('WebPath')."/Search/UnrepliedTickets.html?"; +$link_rel{first} = $BaseURL . $genpage->(1) if $Page > 1; +$link_rel{prev} = $BaseURL . $genpage->($Page - 1) if $Page > 1; +$link_rel{next} = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $ticketcount; +$link_rel{last} = $BaseURL . $genpage->(POSIX::ceil($ticketcount/$Rows)) if $Rows and ($Page * $Rows) < $ticketcount; + +<%CLEANUP> +$session{'tickets'}->PrepForSerialization(); + +<%ARGS> +$HideResults => 0 +$Rows => undef +$Page => 1 +$OrderBy => undef +$Order => undef +$SavedSearchId => undef +$SavedChartSearchId => undef +$Format => undef +