diff options
author | Ivan Kohler <ivan@freeside.biz> | 2016-08-03 17:52:34 -0700 |
---|---|---|
committer | Ivan Kohler <ivan@freeside.biz> | 2016-08-03 17:52:34 -0700 |
commit | cf54023e010df76e0c39ac70902877d7c4c94c6e (patch) | |
tree | 8109fa975ae8ecb4435172f9a990435a734c67b6 /FS | |
parent | d7759b49c2ff3b220ab328767645bfed85d18f31 (diff) | |
parent | ecf1c9cc0a20be6e489657e005ea771977b9f69c (diff) |
Merge branch 'master' of git.freeside.biz:/home/git/freeside
Diffstat (limited to 'FS')
29 files changed, 883 insertions, 47 deletions
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index d767e910d..685821bad 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -1627,6 +1627,34 @@ sub insert_payby { } +sub update_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my $cust_payby = qsearchs('cust_payby', { + 'custnum' => $custnum, + 'custpaybynum' => $p->{'custpaybynum'}, + }) + or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} }; + + foreach my $field ( + qw( weight payby payinfo paycvv paydate payname paystate paytype payip ) + ) { + next unless exists($p->{$field}); + $cust_payby->set($field,$p->{$field}); + } + + my $error = $cust_payby->replace; + if ( $error ) { + return { 'error' => $error }; + } else { + return { 'custpaybynum' => $cust_payby->custpaybynum }; + } + +} + sub verify_payby { my $p = shift; diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm index 622f3df05..08c6c2d59 100644 --- a/FS/FS/ClientAPI_XMLRPC.pm +++ b/FS/FS/ClientAPI_XMLRPC.pm @@ -129,6 +129,7 @@ sub ss2clientapi { 'list_invoices' => 'MyAccount/list_invoices', #? 'list_payby' => 'MyAccount/list_payby', 'insert_payby' => 'MyAccount/insert_payby', + 'update_payby' => 'MyAccount/update_payby', 'delete_payby' => 'MyAccount/delete_payby', 'cancel' => 'MyAccount/cancel', #add to ss cgi! 'payment_info' => 'MyAccount/payment_info', diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 94b8839a1..1b50006a5 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -4950,6 +4950,13 @@ and customer address. Include units.', # }, { + 'key' => 'cdr-skip_duplicate_rewrite', + 'section' => 'telephony', + 'description' => 'Use the freeside-cdrrewrited daemon to prevent billing CDRs with a src, dst and calldate identical to an existing CDR', + 'type' => 'checkbox', + }, + + { 'key' => 'cdr-charged_party_rewrite', 'section' => 'telephony', 'description' => 'Do charged party rewriting in the freeside-cdrrewrited daemon; useful if CDRs are being dropped off directly in the database and require special charged_party processing such as cdr-charged_party-accountcode or cdr-charged_party-truncate*.', diff --git a/FS/FS/Log.pm b/FS/FS/Log.pm index 2fd002093..aed1f3969 100644 --- a/FS/FS/Log.pm +++ b/FS/FS/Log.pm @@ -5,13 +5,20 @@ use FS::Record qw(qsearch qsearchs); use FS::Conf; use FS::Log::Output; use FS::log; -use vars qw(@STACK @LEVELS); +use vars qw(@STACK %LEVELS); # override the stringification of @_ with something more sensible. BEGIN { - @LEVELS = qw(debug info notice warning error critical alert emergency); + # subset of Log::Dispatch levels + %LEVELS = ( + 0 => 'debug', + 1 => 'info', + 3 => 'warning', + 4 => 'error', + 5 => 'critical' + ); - foreach my $l (@LEVELS) { + foreach my $l (values %LEVELS) { my $sub = sub { my $self = shift; $self->log( level => $l, message => @_ ); @@ -100,4 +107,24 @@ sub DESTROY { splice(@STACK, $self->{'index'}, 1); # delete the stack entry } +=item levelnums + +Subroutine. Returns ordered list of level nums. + +=cut + +sub levelnums { + sort keys %LEVELS; +} + +=item levelmap + +Subroutine. Returns ordered map of level num => level name. + +=cut + +sub levelmap { + map { $_ => $LEVELS{$_} } levelnums; +} + 1; diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 1008fd5d8..245bdea88 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -413,6 +413,8 @@ if ( -e $addl_handler_use_file ) { use FS::olt_site; use FS::access_user_page_pref; use FS::part_svc_msgcat; + use FS::commission_schedule; + use FS::commission_rate; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm index 7f76d9988..c3d397389 100644 --- a/FS/FS/Record.pm +++ b/FS/FS/Record.pm @@ -2,6 +2,7 @@ package FS::Record; use base qw( Exporter ); use strict; +use charnames ':full'; use vars qw( $AUTOLOAD %virtual_fields_cache %fk_method_cache $fk_table_cache $money_char $lat_lower $lon_upper @@ -2913,6 +2914,10 @@ sub ut_coord { my $coord = $self->getfield($field); my $neg = $coord =~ s/^(-)//; + # ignore degree symbol at the end, + # but not otherwise supporting degree/minutes/seconds symbols + $coord =~ s/\N{DEGREE SIGN}\s*$//; + my ($d, $m, $s) = (0, 0, 0); if ( @@ -3220,6 +3225,22 @@ sub ut_agentnum_acl { } +=item trim_whitespace FIELD[, FIELD ... ] + +Strip leading and trailing spaces from the value in the named FIELD(s). + +=cut + +sub trim_whitespace { + my $self = shift; + foreach my $field (@_) { + my $value = $self->get($field); + $value =~ s/^\s+//; + $value =~ s/\s+$//; + $self->set($field, $value); + } +} + =item fields [ TABLE ] This is a wrapper for real_fields. Code that called diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index ac585108e..8661c4b97 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1361,6 +1361,7 @@ sub tables_hashref { 'commission_agentnum', 'int', 'NULL', '', '', '', # 'commission_salesnum', 'int', 'NULL', '', '', '', # 'commission_pkgnum', 'int', 'NULL', '', '', '', # + 'commission_invnum', 'int', 'NULL', '', '', '', 'credbatch', 'varchar', 'NULL', $char_d, '', '', ], 'primary_key' => 'crednum', @@ -1396,6 +1397,10 @@ sub tables_hashref { table => 'cust_pkg', references => [ 'pkgnum' ], }, + { columns => [ 'commission_invnum' ], + table => 'cust_bill', + references => [ 'invnum' ], + }, ], }, @@ -1417,6 +1422,7 @@ sub tables_hashref { 'commission_agentnum', 'int', 'NULL', '', '', '', 'commission_salesnum', 'int', 'NULL', '', '', '', 'commission_pkgnum', 'int', 'NULL', '', '', '', + 'commission_invnum', 'int', 'NULL', '', '', '', #void fields 'void_date', @date_type, '', '', 'void_reason', 'varchar', 'NULL', $char_d, '', '', @@ -1456,6 +1462,10 @@ sub tables_hashref { table => 'cust_pkg', references => [ 'pkgnum' ], }, + { columns => [ 'commission_invnum' ], + table => 'cust_bill', + references => [ 'invnum' ], + }, { columns => [ 'void_reasonnum' ], table => 'reason', references => [ 'reasonnum' ], @@ -7438,6 +7448,36 @@ sub tables_hashref { ], }, + 'commission_schedule' => { + 'columns' => [ + 'schedulenum', 'serial', '', '', '', '', + 'schedulename', 'varchar', '', $char_d, '', '', + 'reasonnum', 'int', 'NULL', '', '', '', + 'basis', 'varchar', 'NULL', 32, '', '', + ], + 'primary_key' => 'schedulenum', + 'unique' => [], + 'index' => [], + }, + + 'commission_rate' => { + 'columns' => [ + 'commissionratenum', 'serial', '', '', '', '', + 'schedulenum', 'int', '', '', '', '', + 'cycle', 'int', '', '', '', '', + 'amount', @money_type, '', '', + 'percent', 'decimal','', '7,4', '', '', + ], + 'primary_key' => 'commissionratenum', + 'unique' => [ [ 'schedulenum', 'cycle', ] ], + 'index' => [], + 'foreign_keys' => [ + { columns => [ 'schedulenum' ], + table => 'commission_schedule', + }, + ], + }, + # name type nullability length default local #'new_table' => { diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm index db7010c18..3e3e7e520 100644 --- a/FS/FS/TaxEngine/internal.pm +++ b/FS/FS/TaxEngine/internal.pm @@ -28,8 +28,10 @@ sub add_sale { push @{ $self->{items} }, $cust_bill_pkg; - my @loc_keys = qw( district city county state country ); - my %taxhash = map { $_ => $location->get($_) } @loc_keys; + my %taxhash = map { $_ => $location->get($_) } + qw( district county state country ); + # city names in cust_main_county are uppercase + $taxhash{'city'} = uc($location->get('city')); $taxhash{'taxclass'} = $part_item->taxclass; diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 6f14cd202..3faf47e24 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -352,6 +352,9 @@ sub upgrade_data { tie my %hash, 'Tie::IxHash', + #remap log levels + 'log' => [], + #cust_main (remove paycvv from history, locations, cust_payby, etc) 'cust_main' => [], @@ -478,8 +481,12 @@ sub upgrade_data { #populate tax statuses 'tax_status' => [], - #mark certain taxes as system-maintained + #mark certain taxes as system-maintained, + # and fix whitespace 'cust_main_county' => [], + + #fix whitespace + 'cust_location' => [], ; \%hash; diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm index 0ee0aa04a..13a826f29 100644 --- a/FS/FS/access_right.pm +++ b/FS/FS/access_right.pm @@ -253,7 +253,9 @@ sub _upgrade_data { # class method 'Generate quotation' => 'Disable quotation', 'Add on-the-fly void credit reason' => 'Add on-the-fly void reason', '_ALL' => 'Employee preference telephony integration', - 'Edit customer package dates' => 'Change package start date', #4.x + 'Edit customer package dates' => [ 'Change package start date', #4.x + 'Change package contract end date', + ], 'Resend invoices' => 'Print and mail invoices', ); diff --git a/FS/FS/commission_rate.pm b/FS/FS/commission_rate.pm new file mode 100644 index 000000000..dcb596d60 --- /dev/null +++ b/FS/FS/commission_rate.pm @@ -0,0 +1,116 @@ +package FS::commission_rate; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::commission_rate - Object methods for commission_rate records + +=head1 SYNOPSIS + + use FS::commission_rate; + + $record = new FS::commission_rate \%hash; + $record = new FS::commission_rate { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::commission_rate object represents a commission rate (a percentage or a +flat amount) that will be paid on a customer's N-th invoice. The sequence of +commissions that will be paid on consecutive invoices is the parent object, +L<FS::commission_schedule>. + +FS::commission_rate inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item commissionratenum - primary key + +=item schedulenum - L<FS::commission_schedule> foreign key + +=item cycle - the ordinal of the billing cycle this commission will apply +to. cycle = 1 applies to the customer's first invoice, cycle = 2 to the +second, etc. + +=item amount - the flat amount to pay per invoice in commission + +=item percent - the percentage of the invoice amount to pay in +commission + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new commission rate. To add it to the database, see L<"insert">. + +=cut + +sub table { 'commission_rate'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=item check + +Checks all fields to make sure this is a valid commission rate. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + $self->set('amount', '0.00') + if $self->get('amount') eq ''; + $self->set('percent', '0') + if $self->get('percent') eq ''; + + my $error = + $self->ut_numbern('commissionratenum') + || $self->ut_number('schedulenum') + || $self->ut_number('cycle') + || $self->ut_money('amount') + || $self->ut_decimal('percent') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 SEE ALSO + +L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/commission_schedule.pm b/FS/FS/commission_schedule.pm new file mode 100644 index 000000000..375386c33 --- /dev/null +++ b/FS/FS/commission_schedule.pm @@ -0,0 +1,235 @@ +package FS::commission_schedule; +use base qw( FS::o2m_Common FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); +use FS::commission_rate; +use Tie::IxHash; + +tie our %basis_options, 'Tie::IxHash', ( + setuprecur => 'Total sales', + setup => 'One-time and setup charges', + recur => 'Recurring charges', + setup_cost => 'Setup costs', + recur_cost => 'Recurring costs', + setup_margin => 'Setup charges minus costs', + recur_margin_permonth => 'Monthly recurring charges minus costs', +); + +=head1 NAME + +FS::commission_schedule - Object methods for commission_schedule records + +=head1 SYNOPSIS + + use FS::commission_schedule; + + $record = new FS::commission_schedule \%hash; + $record = new FS::commission_schedule { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::commission_schedule object represents a bundle of one or more +commission rates for invoices. FS::commission_schedule inherits from +FS::Record. The following fields are currently supported: + +=over 4 + +=item schedulenum - primary key + +=item schedulename - descriptive name + +=item reasonnum - the credit reason (L<FS::reason>) that will be assigned +to these commission credits + +=item basis - for percentage credits, which component of the invoice charges +the percentage will be calculated on: +- setuprecur (total charges) +- setup +- recur +- setup_cost +- recur_cost +- setup_margin (setup - setup_cost) +- recur_margin_permonth ((recur - recur_cost) / freq) + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new commission schedule. To add the object to the database, see +L<"insert">. + +=cut + +sub table { 'commission_schedule'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=cut + +sub delete { + my $self = shift; + # don't allow the schedule to be removed if it's still linked to events + if ($self->part_event) { + return 'This schedule is still in use.'; # UI should be smarter + } + $self->process_o2m( + 'table' => 'commission_rate', + 'params' => [], + ) || $self->delete; +} + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('schedulenum') + || $self->ut_text('schedulename') + || $self->ut_number('reasonnum') + || $self->ut_enum('basis', [ keys %basis_options ]) + ; + return $error if $error; + + $self->SUPER::check; +} + +=item part_event + +Returns a list of billing events (L<FS::part_event> objects) that pay +commission on this schedule. + +=cut + +sub part_event { + my $self = shift; + map { $_->part_event } + qsearch('part_event_option', { + optionname => 'schedulenum', + optionvalue => $self->schedulenum, + } + ); +} + +=item calc_credit INVOICE + +Takes an L<FS::cust_bill> object and calculates credit on this schedule. +Returns the amount to credit. If there's no rate defined for this invoice, +returns nothing. + +=cut + +# Some false laziness w/ FS::part_event::Action::Mixin::credit_bill. +# this is a little different in that we calculate the credit on the whole +# invoice. + +sub calc_credit { + my $self = shift; + my $cust_bill = shift; + die "cust_bill record required" if !$cust_bill or !$cust_bill->custnum; + # count invoices before or including this one + my $cycle = FS::cust_bill->count('custnum = ? AND _date <= ?', + $cust_bill->custnum, + $cust_bill->_date + ); + my $rate = qsearchs('commission_rate', { + schedulenum => $self->schedulenum, + cycle => $cycle, + }); + # we might do something with a rate that applies "after the end of the + # schedule" (cycle = 0 or something) so that this can do commissions with + # no end date. add that here if there's a need. + return unless $rate; + + my $amount; + if ( $rate->percent ) { + my $what = $self->basis; + my $cost = ($what =~ /_cost/ ? 1 : 0); + my $margin = ($what =~ /_margin/ ? 1 : 0); + my %part_pkg_cache; + foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) { + + my $charge = 0; + next if !$cust_bill_pkg->pkgnum; # exclude taxes and fees + + my $cust_pkg = $cust_bill_pkg->cust_pkg; + if ( $margin or $cost ) { + # look up package costs only if we need them + my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; + my $part_pkg = $part_pkg_cache{$pkgpart} + ||= FS::part_pkg->by_key($pkgpart); + + if ( $cost ) { + $charge = $part_pkg->get($what); + } else { # $margin + $charge = $part_pkg->$what($cust_pkg); + } + + $charge = ($charge || 0) * ($cust_pkg->quantity || 1); + + } else { + + if ( $what eq 'setup' ) { + $charge = $cust_bill_pkg->get('setup'); + } elsif ( $what eq 'recur' ) { + $charge = $cust_bill_pkg->get('recur'); + } elsif ( $what eq 'setuprecur' ) { + $charge = $cust_bill_pkg->get('setup') + + $cust_bill_pkg->get('recur'); + } + } + + $amount += ($charge * $rate->percent / 100); + + } + } # if $rate->percent + + if ( $rate->amount ) { + $amount += $rate->amount; + } + + $amount = sprintf('%.2f', $amount + 0.005); + return $amount; +} + +=back + +=head1 SEE ALSO + +L<FS::Record>, L<FS::part_event>, L<FS::commission_rate> + +=cut + +1; + diff --git a/FS/FS/cust_bill_pkg_tax_location.pm b/FS/FS/cust_bill_pkg_tax_location.pm index 9a1f22a02..7c67c2df8 100644 --- a/FS/FS/cust_bill_pkg_tax_location.pm +++ b/FS/FS/cust_bill_pkg_tax_location.pm @@ -338,7 +338,7 @@ sub upgrade_taxable_billpkgnum { } #for $i } else { # the more complicated case - $log->warn("mismatched charges and tax links in pkg#$pkgnum", + $log->warning("mismatched charges and tax links in pkg#$pkgnum", object => $cust_bill); my $tax_amount = sum(map {$_->amount} @tax_links); # remove all tax link records and recreate them to be 1:1 with diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm index 85463724c..e4b1fc07d 100644 --- a/FS/FS/cust_credit.pm +++ b/FS/FS/cust_credit.pm @@ -315,6 +315,7 @@ sub check { || $self->ut_foreign_keyn('commission_agentnum', 'agent', 'agentnum') || $self->ut_foreign_keyn('commission_salesnum', 'sales', 'salesnum') || $self->ut_foreign_keyn('commission_pkgnum', 'cust_pkg', 'pkgnum') + || $self->ut_foreign_keyn('commission_invnum', 'cust_bill', 'invnum') ; return $error if $error; diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm index 90400984c..fdc2cf8da 100644 --- a/FS/FS/cust_location.pm +++ b/FS/FS/cust_location.pm @@ -2,7 +2,7 @@ package FS::cust_location; use base qw( FS::geocode_Mixin FS::Record ); use strict; -use vars qw( $import $DEBUG $conf $label_prefix ); +use vars qw( $import $DEBUG $conf $label_prefix $allow_location_edit ); use Data::Dumper; use Date::Format qw( time2str ); use FS::UID qw( dbh driver_name ); @@ -171,6 +171,10 @@ sub find_or_insert { delete $nonempty{'locationnum'}; my %hash = map { $_ => $self->get($_) } @essential; + foreach (values %hash) { + s/^\s+//; + s/\s+$//; + } my @matches = qsearch('cust_location', \%hash); # we no longer reject matches for having different values in nonessential @@ -292,7 +296,7 @@ sub replace { # it's a prospect location, then there are no active packages, no billing # history, no taxes, and in general no reason to keep the old location # around. - if ( $self->custnum ) { + if ( !$allow_location_edit and $self->custnum ) { foreach (qw(address1 address2 city state zip country)) { if ( $self->$_ ne $old->$_ ) { return "can't change cust_location field $_"; @@ -347,6 +351,10 @@ sub check { return '' if $self->disabled; # so that disabling locations never fails + # maybe should just do all fields in the table? + # or in every table? + $self->trim_whitespace(qw(district city county state country)); + my $error = $self->ut_numbern('locationnum') || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum') @@ -887,6 +895,35 @@ sub process_standardize { close $log; } +sub _upgrade_data { + my $class = shift; + + # are we going to need to update tax districts? + my $use_districts = $conf->config('tax_district_method') ? 1 : 0; + + # trim whitespace on records that need it + local $allow_location_edit = 1; + foreach my $field (qw(city county state country district)) { + foreach my $location (qsearch({ + table => 'cust_location', + extra_sql => " WHERE $field LIKE ' %' OR $field LIKE '% '" + })) { + my $error = $location->replace; + die "$error (fixing whitespace in $field, locationnum ".$location->locationnum.')' + if $error; + + if ( $use_districts ) { + my $queue = new FS::queue { + 'job' => 'FS::geocode_Mixin::process_district_update' + }; + $error = $queue->insert( 'FS::cust_location' => $location->locationnum ); + die $error if $error; + } + } # foreach $location + } # foreach $field + ''; +} + =head1 BUGS =head1 SEE ALSO diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 0fc2cb7e0..3e4a438d6 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -355,6 +355,35 @@ sub _bop_content { \%content; } +sub _tokenize_card { + my ($self,$transaction,$payinfo,$log) = @_; + + if ( $transaction->can('card_token') + and $transaction->card_token + and $payinfo !~ /^99\d{14}$/ #not already tokenized + ) { + + my @cust_payby = $self->cust_payby('CARD','DCRD'); + @cust_payby = grep { $payinfo == $_->payinfo } @cust_payby; + if (@cust_payby > 1) { + $log->error('Multiple matching card numbers for cust '.$self->custnum.', could not tokenize card'); + } elsif (@cust_payby) { + my $cust_payby = $cust_payby[0]; + $cust_payby->payinfo($transaction->card_token); + my $error = $cust_payby->replace; + if ( $error ) { + $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error); + } else { + $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum); + } + } else { + $log->debug('No matching card numbers for cust '.$self->custnum.', could not tokenize card'); + } + + } + +} + my %bop_method2payby = ( 'CC' => 'CARD', 'ECHECK' => 'CHEK', @@ -369,6 +398,8 @@ sub realtime_bop { unless $FS::UID::AutoCommit; local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_bop'); my %options = (); if (ref($_[0]) eq 'HASH') { @@ -774,18 +805,7 @@ sub realtime_bop { # Tokenize ### - - if ( $transaction->can('card_token') && $transaction->card_token ) { - - if ( $options{'payinfo'} eq $self->payinfo ) { - $self->payinfo($transaction->card_token); - my $error = $self->replace; - if ( $error ) { - warn "WARNING: error storing token: $error, but proceeding anyway\n"; - } - } - - } + $self->_tokenize_card($transaction,$options{'payinfo'},$log); ### # result handling @@ -1950,6 +1970,7 @@ sub realtime_verify_bop { if ( $reverse->is_success ) { $cust_pay_pending->status('done'); + $cust_pay_pending->statustext('reversed'); my $cpp_authorized_err = $cust_pay_pending->replace; return $cpp_authorized_err if $cpp_authorized_err; @@ -2083,19 +2104,7 @@ sub realtime_verify_bop { # Tokenize ### - if ( $transaction->can('card_token') && $transaction->card_token ) { - - if ( $options{'payinfo'} eq $self->payinfo ) { - $self->payinfo($transaction->card_token); - my $error = $self->replace; - if ( $error ) { - my $warning = "WARNING: error storing token: $error, but proceeding anyway\n"; - $log->warning($warning); - warn $warning; - } - } - - } + $self->_tokenize_card($transaction,$options{'payinfo'},$log); ### # result handling diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm index 3c355e823..a1233d083 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -122,6 +122,9 @@ methods. sub check { my $self = shift; + $self->trim_whitespace(qw(district city county state country)); + $self->set('city', uc($self->get('city'))); # also county? + $self->exempt_amount(0) unless $self->exempt_amount; $self->ut_numbern('taxnum') @@ -701,6 +704,49 @@ sub _upgrade_data { } FS::upgrade_journal->set_done($journal); } + # trim whitespace and convert to uppercase in the 'city' field. + foreach my $record (qsearch({ + table => 'cust_main_county', + extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)", + })) { + # any with-trailing-space records probably duplicate other records + # from the same city, and if we just fix the record in place, we'll + # create an exact duplicate. + # so find the record this one would duplicate, and merge them. + $record->check; # trims whitespace + my %match = map { $_ => $record->get($_) } + qw(city county state country district taxname taxclass); + my $other = qsearchs('cust_main_county', \%match); + if ($other) { + my $new_taxnum = $other->taxnum; + my $old_taxnum = $record->taxnum; + if ($other->tax != $record->tax or + $other->exempt_amount != $record->exempt_amount) { + # don't assume these are the same. + warn "Found duplicate taxes (#$new_taxnum and #$old_taxnum) but they have different rates and can't be merged.\n"; + } else { + warn "Merging tax #$old_taxnum into #$new_taxnum\n"; + foreach my $table (qw( + cust_bill_pkg_tax_location + cust_bill_pkg_tax_location_void + cust_tax_exempt_pkg + cust_tax_exempt_pkg_void + )) { + foreach my $row (qsearch($table, { 'taxnum' => $old_taxnum })) { + $row->set('taxnum' => $new_taxnum); + my $error = $row->replace; + die $error if $error; + } + } + my $error = $record->delete; + die $error if $error; + } + } else { + # else there is no record this one duplicates, so just fix it + my $error = $record->replace; + die $error if $error; + } + } # foreach $record ''; } diff --git a/FS/FS/cust_pay_pending.pm b/FS/FS/cust_pay_pending.pm index dfb07b84d..3a8322e06 100644 --- a/FS/FS/cust_pay_pending.pm +++ b/FS/FS/cust_pay_pending.pm @@ -455,6 +455,26 @@ sub decline { $self->replace; } +=item reverse [ STATUSTEXT ] + +Sets the status of this pending payment to "done" (with statustext +"reversed (manual)" unless otherwise specified). + +Currently only used when resolving pending payments manually. + +=cut + +# almost complete false laziness with decline, +# but want to avoid confusion, in case any additional steps/defaults are ever added to either +sub reverse { + my $self = shift; + my $statustext = shift || "reversed (manual)"; + + $self->status('done'); + $self->statustext($statustext); + $self->replace; +} + # _upgrade_data # # Used by FS::Upgrade to migrate to a new database. diff --git a/FS/FS/cust_pkg_reason.pm b/FS/FS/cust_pkg_reason.pm index 29b4b0a91..a632ab415 100644 --- a/FS/FS/cust_pkg_reason.pm +++ b/FS/FS/cust_pkg_reason.pm @@ -3,7 +3,7 @@ use base qw( FS::otaker_Mixin FS::Record ); use strict; use vars qw( $ignore_empty_action ); -use FS::Record qw( qsearch ); #qsearchs ); +use FS::Record qw( qsearch qsearchs ); use FS::upgrade_journal; $ignore_empty_action = 0; diff --git a/FS/FS/log.pm b/FS/FS/log.pm index 1d4df730a..d432ee3c6 100644 --- a/FS/FS/log.pm +++ b/FS/FS/log.pm @@ -6,6 +6,8 @@ use FS::Record qw( qsearch qsearchs dbdef ); use FS::UID qw( dbh driver_name ); use FS::log_context; use FS::log_email; +use FS::upgrade_journal; +use Tie::IxHash; =head1 NAME @@ -115,7 +117,7 @@ sub insert { 'msgtype' => 'admin', 'to' => $log_email->to_addr, 'substitutions' => { - 'loglevel' => $FS::Log::LEVELS[$self->level], # which has hopefully been loaded... + 'loglevel' => $FS::Log::LEVELS{$self->level}, # which has hopefully been loaded... 'logcontext' => $log_email->context, # use the one that triggered the email 'logmessage' => $self->message, }, @@ -383,6 +385,49 @@ sub search { }; } +sub _upgrade_data { + my ($class, %opts) = @_; + + return if FS::upgrade_journal->is_done('log__remap_levels'); + + tie my %levelmap, 'Tie::IxHash', + 2 => 1, #notice -> info + 6 => 5, #alert -> critical + 7 => 5, #emergency -> critical + ; + + # this method should never autocommit + # should have been set in upgrade, but just in case... + local $FS::UID::AutoCommit = 0; + + # in practice, only debug/info/warning/error appear to have been used, + # so this probably won't do anything, but just in case + foreach my $old (keys %levelmap) { + # FS::log has no replace method + my $sql = 'UPDATE log SET level=' . dbh->quote($levelmap{$old}) . ' WHERE level=' . dbh->quote($old); + warn $sql unless $opts{'quiet'}; + my $sth = dbh->prepare($sql) or die dbh->errstr; + $sth->execute() or die $sth->errstr; + $sth->finish(); + } + + foreach my $log_email ( + qsearch('log_email',{ 'min_level' => 2 }), + qsearch('log_email',{ 'min_level' => 6 }), + qsearch('log_email',{ 'min_level' => 7 }), + ) { + $log_email->min_level($levelmap{$log_email->min_level}); + my $error = $log_email->replace; + if ($error) { + dbh->rollback; + die $error; + } + } + + FS::upgrade_journal->set_done('log__remap_levels'); + +} + =back =head1 BUGS diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 83414a680..37befb515 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -5,10 +5,10 @@ use base qw( FS::Record ); use FS::Record qw( qsearch qsearchs ); my @contexts = ( qw( - test bill_and_collect FS::cust_main::Billing::bill_and_collect FS::cust_main::Billing::bill + FS::cust_main::Billing_Realtime::realtime_bop FS::cust_main::Billing_Realtime::realtime_verify_bop FS::pay_batch::import_from_gateway FS::part_pkg @@ -23,6 +23,7 @@ my @contexts = ( qw( upgrade_taxable_billpkgnum freeside-paymentech-upload freeside-paymentech-download + test ) ); =head1 NAME diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index b89071710..0a16724a8 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -804,6 +804,59 @@ sub _upgrade_data { ### $self->_populate_initial_data; + ### + # Move welcome_msgnum to an export + ### + + #upgrade_journal loaded by _populate_initial_data + unless (FS::upgrade_journal->is_done('msg_template__welcome_export')) { + if (my $msgnum = $conf->config('welcome_msgnum')) { + eval "use FS::part_export;"; + die $@ if $@; + eval "use FS::part_svc;"; + die $@ if $@; + eval "use FS::export_svc;"; + die $@ if $@; + #create the export + my $part_export = new FS::part_export { + 'exportname' => 'Welcome Email', + 'exporttype' => 'send_email' + }; + my $error = $part_export->insert({ + 'to_customer' => 1, + 'insert_template' => $msgnum, + # replicate blank options that would be generated by UI, + # to avoid unexpected results from not having them exist + 'to_address' => '', + 'replace_template' => 0, + 'suspend_template' => 0, + 'unsuspend_template' => 0, + 'delete_template' => 0, + }); + die $error if $error; + #attach it to part_svcs + my @welcome_exclude_svcparts = $conf->config('svc_acct_welcome_exclude'); + foreach my $part_svc ( + qsearch('part_svc',{ 'svcdb' => 'svc_acct', 'disabled' => '' }) + ) { + next if grep { $_ eq $part_svc->svcpart } @welcome_exclude_svcparts; + my $export_svc = new FS::export_svc { + 'exportnum' => $part_export->exportnum, + 'svcpart' => $part_svc->svcpart, + }; + $error = $export_svc->insert; + die $error if $error; + } + #remove the old confs + $error = $conf->delete('welcome_msgnum'); + die $error if $error; + $error = $conf->delete('svc_acct_welcome_exclude'); + die $error if $error; + } + FS::upgrade_journal->set_done('msg_template__welcome_export'); + } + + ### Fix dump-email_to (needs to happen after _populate_initial_data) if ($conf->config('dump-email_to')) { # anyone who still uses dump-email_to should have just had this created diff --git a/FS/FS/part_event/Action/bill_agent_credit_schedule.pm b/FS/FS/part_event/Action/bill_agent_credit_schedule.pm new file mode 100644 index 000000000..31189a237 --- /dev/null +++ b/FS/FS/part_event/Action/bill_agent_credit_schedule.pm @@ -0,0 +1,76 @@ +package FS::part_event::Action::bill_agent_credit_schedule; + +use base qw( FS::part_event::Action ); +use FS::Conf; +use FS::cust_credit; +use FS::commission_schedule; +use Date::Format qw(time2str); + +use strict; + +sub description { 'Credit the agent based on a commission schedule' } + +sub option_fields { + 'schedulenum' => { 'label' => 'Schedule', + 'type' => 'select-table', + 'table' => 'commission_schedule', + 'name_col' => 'schedulename', + 'disable_empty'=> 1, + }, +} + +sub eventtable_hashref { + { 'cust_bill' => 1 }; +} + +our $date_format; + +sub do_action { + my( $self, $cust_bill, $cust_event ) = @_; + + $date_format ||= FS::Conf->new->config('date_format') || '%x'; + + my $cust_main = $self->cust_main($cust_bill); + my $agent = $cust_main->agent; + return "No customer record for agent ". $agent->agent + unless $agent->agent_custnum; + + my $agent_cust_main = $agent->agent_cust_main; + + my $schedulenum = $self->option('schedulenum') + or return "no commission schedule selected"; + my $schedule = FS::commission_schedule->by_key($schedulenum) + or return "commission schedule #$schedulenum not found"; + # commission_schedule::delete tries to prevent this, but just in case + + my $amount = $schedule->calc_credit($cust_bill) + or return; + + my $reasonnum = $schedule->reasonnum; + + #XXX shouldn't do this here, it's a localization problem. + # credits with commission_invnum should know how to display it as part + # of invoice rendering. + my $desc = 'from invoice #'. $cust_bill->display_invnum . + ' ('. time2str($date_format, $cust_bill->_date) . ')'; + # could also show custnum and pkgnums here? + my $cust_credit = FS::cust_credit->new({ + 'custnum' => $agent_cust_main->custnum, + 'reasonnum' => $reasonnum, + 'amount' => $amount, + 'eventnum' => $cust_event->eventnum, + 'addlinfo' => $desc, + 'commission_agentnum' => $cust_main->agentnum, + 'commission_invnum' => $cust_bill->invnum, + }); + my $error = $cust_credit->insert; + die "Error crediting customer ". $agent_cust_main->custnum. + " for agent commission: $error" + if $error; + + #return $warning; # currently don't get warnings here + return; + +} + +1; diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 4f26e8c6f..5f7ce3550 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -420,15 +420,30 @@ sub paydate_epoch_sql { Find all records with a credit card payment type and no paycardtype, and replace them in order to set their paycardtype. +This method actually just starts a queue job. + =cut sub upgrade_set_cardtype { my $class = shift; + my $table = $class->table or die "upgrade_set_cardtype needs a table"; + + if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) { + my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' }); + my $error = $job->insert($table); + die $error if $error; + FS::upgrade_journal->set_done("${table}__set_cardtype"); + } +} + +sub process_set_cardtype { + my $table = shift; + # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype # will do this. ignore any problems with the cards. local $ignore_masked_payinfo = 1; my $search = FS::Cursor->new({ - table => $class->table, + table => $table, extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ], }); while (my $record = $search->fetch) { diff --git a/FS/FS/svc_Common.pm b/FS/FS/svc_Common.pm index 1dd9ffb63..f2456a56f 100644 --- a/FS/FS/svc_Common.pm +++ b/FS/FS/svc_Common.pm @@ -1481,8 +1481,12 @@ sub search { } #svcnum - if ( $params->{'svcnum'} =~ /^(\d+)$/ ) { - push @where, "svcnum = $1"; + if ( $params->{'svcnum'} ) { + my @svcnum = ref( $params->{'svcnum'} ) + ? @{ $params->{'svcnum'} } + : $params->{'svcnum'}; + @svcnum = grep /^\d+$/, @svcnum; + push @where, 'svcnum IN ('. join(',', @svcnum) . ')' if @svcnum; } # svcpart diff --git a/FS/MANIFEST b/FS/MANIFEST index 83359f118..4184b9ce6 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -870,3 +870,7 @@ FS/webservice_log.pm t/webservice_log.t FS/access_user_page_pref.pm t/access_user_page_pref.t +FS/commission_schedule.pm +t/commission_schedule.t +FS/commission_rate.pm +t/commission_rate.t diff --git a/FS/bin/freeside-cdrrewrited b/FS/bin/freeside-cdrrewrited index 008759008..1745d67a0 100644 --- a/FS/bin/freeside-cdrrewrited +++ b/FS/bin/freeside-cdrrewrited @@ -4,7 +4,7 @@ use strict; use vars qw( $conf ); use FS::Daemon ':all'; #daemonize1 drop_root daemonize2 myexit logfile sig* use FS::UID qw( adminsuidsetup ); -use FS::Record qw( qsearch qsearchs ); +use FS::Record qw( qsearch qsearchs dbh ); #use FS::cdr; #use FS::cust_pkg; #use FS::queue; @@ -24,12 +24,12 @@ daemonize2(); $conf = new FS::Conf; -die "not running; cdr-asterisk_forward_rewrite, cdr-charged_party_rewrite ". - " and cdr-taqua-accountcode_rewrite conf options are all off\n" +die "not running; relevant conf options are all off\n" unless _shouldrun(); #-- +#used for taqua my %sessionnum_unmatch = (); my $sessionnum_retry = 4 * 60 * 60; # 4 hours my $sessionnum_giveup = 4 * 24 * 60 * 60; # 4 days @@ -45,20 +45,25 @@ while (1) { # instead of just doing this search like normal CDRs #hmm :/ + #used only by taqua, should have no effect otherwise my @recent = grep { ($sessionnum_unmatch{$_} + $sessionnum_retry) > time } keys %sessionnum_unmatch; my $extra_sql = scalar(@recent) ? ' AND acctid NOT IN ('. join(',', @recent). ') ' : ''; + #order matters for removing dupes--only the first is preserved + $extra_sql .= ' ORDER BY acctid ' + if $conf->exists('cdr-skip_duplicate_rewrite'); + my $found = 0; - my %skip = (); + my %skip = (); #used only by taqua my %warning = (); foreach my $cdr ( qsearch( { 'table' => 'cdr', - 'extra_sql' => 'FOR UPDATE', + 'extra_sql' => 'FOR UPDATE', #XXX overwritten by opt below...would fixing this break anything? 'hashref' => {}, 'extra_sql' => 'WHERE freesidestatus IS NULL '. ' AND freesiderewritestatus IS NULL '. @@ -67,11 +72,27 @@ while (1) { } ) ) { - next if $skip{$cdr->acctid}; + next if $skip{$cdr->acctid}; #used only by taqua $found = 1; my @status = (); + if ($conf->exists('cdr-skip_duplicate_rewrite')) { + #qsearch can't handle timestamp type of calldate + my $sth = dbh->prepare( + 'SELECT 1 FROM cdr WHERE src=? AND dst=? AND calldate=? AND acctid < ? LIMIT 1' + ) or die dbh->errstr; + $sth->execute($cdr->src,$cdr->dst,$cdr->calldate,$cdr->acctid) or die $sth->errstr; + my $isdup = $sth->fetchrow_hashref; + $sth->finish; + if ($isdup) { + #we only act on this cdr, not touching previous dupes + #if a dupe somehow creeped in previously, too late to fix it + $cdr->freesidestatus('done'); #prevent it from being billed + push(@status,'duplicate'); + } + } + if ( $conf->exists('cdr-asterisk_forward_rewrite') && $cdr->dstchannel =~ /^Local\/(\d+)/i && $1 ne $cdr->dst ) @@ -240,6 +261,7 @@ sub _shouldrun { || $conf->exists('cdr-taqua-accountcode_rewrite') || $conf->exists('cdr-taqua-callerid_rewrite') || $conf->exists('cdr-intl_to_domestic_rewrite') + || $conf->exists('cdr-skip_duplicate_rewrite') || 0 ; } @@ -263,6 +285,11 @@ of the following config options are enabled: =over 4 +=item cdr-skip_duplicate_rewrite + +Marks as 'done' (prevents billing for) any CDRs with +a src, dst and calldate identical to an existing CDR + =item cdr-asterisk_australia_rewrite Classifies Australian numbers as domestic, mobile, tollfree, international, or diff --git a/FS/t/commission_rate.t b/FS/t/commission_rate.t new file mode 100644 index 000000000..fb5f43cc5 --- /dev/null +++ b/FS/t/commission_rate.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::commission_rate; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/commission_schedule.t b/FS/t/commission_schedule.t new file mode 100644 index 000000000..bbe6b42dc --- /dev/null +++ b/FS/t/commission_schedule.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::commission_schedule; +$loaded=1; +print "ok 1\n"; |