diff options
Diffstat (limited to 'FS')
153 files changed, 8850 insertions, 1527 deletions
diff --git a/FS/FS/API.pm b/FS/FS/API.pm index 9dbbc3c4f..6ca2b553b 100644 --- a/FS/FS/API.pm +++ b/FS/FS/API.pm @@ -8,6 +8,7 @@ use FS::cust_location; use FS::cust_pay; use FS::cust_credit; use FS::cust_refund; +use FS::cust_pkg; =head1 NAME @@ -43,7 +44,7 @@ in plaintext. Adds a new payment to a customers account. Takes a list of keys and values as paramters with the following keys: -=over 5 +=over 4 =item secret @@ -65,6 +66,10 @@ Amount paid Option date for payment +=item order_number + +Optional order number + =back Example: @@ -77,6 +82,7 @@ Example: #optional '_date' => 1397977200, #UNIX timestamp + 'order_number' => '12345', ); if ( $result->{'error'} ) { @@ -91,9 +97,7 @@ Example: #enter cash payment sub insert_payment { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); #less "raw" than this? we are the backoffice API, and aren't worried # about version migration ala cust_main/cust_location here @@ -107,19 +111,12 @@ sub insert_payment { # pass the phone number ( from svc_phone ) sub insert_payment_phonenum { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); - $class->_by_phonenum('insert_payment', %opt); - } sub _by_phonenum { my($class, $method, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); my $phonenum = delete $opt{'phonenum'}; @@ -132,7 +129,6 @@ sub _by_phonenum { $opt{'custnum'} = $cust_pkg->custnum; $class->$method(%opt); - } =item insert_credit OPTION => VALUE, ... @@ -183,11 +179,9 @@ Example: #Enter credit sub insert_credit { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); - $opt{'reasonnum'} ||= $conf->config('api_credit_reason'); + $opt{'reasonnum'} ||= FS::Conf->new->config('api_credit_reason'); #less "raw" than this? we are the backoffice API, and aren't worried # about version migration ala cust_main/cust_location here @@ -201,12 +195,38 @@ sub insert_credit { # pass the phone number ( from svc_phone ) sub insert_credit_phonenum { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); - $class->_by_phonenum('insert_credit', %opt); +} + +=item apply_payments_and_credits + +Applies payments and credits for this customer. Takes a list of keys and +values as parameter with the following keys: + +=over 4 + +=item secret + +API secret + +=item custnum + +Customer number + +=back + +=cut + +#apply payments and credits +sub apply_payments_and_credits { + my($class, %opt) = @_; + return _shared_secret_error() unless _check_shared_secret($opt{secret}); + + my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} }) + or return { 'error' => 'Unknown custnum' }; + my $error = $cust_main->apply_payments_and_credits( 'manual'=>1 ); + return { 'error' => $error, }; } =item insert_refund OPTION => VALUE, ... @@ -238,9 +258,7 @@ Example: #Enter cash refund. sub insert_refund { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); # when github pull request #24 is merged, # will have to change over to default reasonnum like credit @@ -259,12 +277,7 @@ sub insert_refund { # pass the phone number ( from svc_phone ) sub insert_refund_phonenum { my($class, %opt) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); - $class->_by_phonenum('insert_refund', %opt); - } #--- @@ -397,16 +410,13 @@ Referring customer number sub new_customer { my( $class, %opt ) = @_; - - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); #default agentnum like signup_server-default_agentnum? #$opt{agentnum} ||= $conf->config('signup_server-default_agentnum'); #same for refnum like signup_server-default_refnum - $opt{refnum} ||= $conf->config('signup_server-default_refnum'); + $opt{refnum} ||= FS::Conf->new->config('signup_server-default_refnum'); $class->API_insert( %opt ); } @@ -505,10 +515,7 @@ Agent number sub update_customer { my( $class, %opt ) = @_; - - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); FS::cust_main->API_update( %opt ); } @@ -522,9 +529,7 @@ parameters with the following keys: custnum, secret sub customer_info { my( $class, %opt ) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} }) or return { 'error' => 'Unknown custnum' }; @@ -545,9 +550,7 @@ and values as paramters with the following keys: custnum, secret sub location_info { my( $class, %opt ) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} }); @@ -559,6 +562,87 @@ sub location_info { return \%return; } +=item change_package_location + +Updates package location. Takes a list of keys and values +as paramters with the following keys: + +pkgnum + +secret + +locationnum - pass this, or the following keys (don't pass both) + +locationname + +address1 + +address2 + +city + +county + +state + +zip + +addr_clean + +country + +censustract + +censusyear + +location_type + +location_number + +location_kind + +incorporated + +On error, returns a hashref with an 'error' key. +On success, returns a hashref with 'pkgnum' and 'locationnum' keys, +containing the new values. + +=cut + +sub change_package_location { + my $class = shift; + my %opt = @_; + return _shared_secret_error() unless _check_shared_secret($opt{'secret'}); + + my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} }) + or return { 'error' => 'Unknown pkgnum' }; + + my %changeopt; + + foreach my $field ( qw( + locationnum + locationname + address1 + address2 + city + county + state + zip + addr_clean + country + censustract + censusyear + location_type + location_number + location_kind + incorporated + )) { + $changeopt{$field} = $opt{$field} if $opt{$field}; + } + + $cust_pkg->API_change(%changeopt); +} + =item bill_now OPTION => VALUE, ... Bills a single customer now, in the same fashion as the "Bill now" link in the @@ -584,9 +668,7 @@ Customer number (required) sub bill_now { my( $class, %opt ) = @_; - my $conf = new FS::Conf; - return { 'error' => 'Incorrect shared secret' } - unless $opt{secret} eq $conf->config('api_shared_secret'); + return _shared_secret_error() unless _check_shared_secret($opt{secret}); my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} }) or return { 'error' => 'Unknown custnum' }; @@ -602,7 +684,19 @@ sub bill_now { } -#Advertising sources? +#next.. Advertising sources? + +## +# helper subroutines +## + +sub _check_shared_secret { + shift eq FS::Conf->new->config('api_shared_secret'); +} + +sub _shared_secret_error { + return { 'error' => 'Incorrect shared secret' }; +} 1; diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm index a24d736ee..dac349eaf 100644 --- a/FS/FS/AccessRight.pm +++ b/FS/FS/AccessRight.pm @@ -143,6 +143,7 @@ tie my %rights, 'Tie::IxHash', 'Cancel customer package later', 'Un-cancel customer package', 'Delay suspension events', + 'Customize billing during suspension', 'Add on-the-fly cancel reason', #NEW 'Add on-the-fly suspend reason', #NEW 'Edit customer package invoice details', #NEW @@ -153,6 +154,7 @@ tie my %rights, 'Tie::IxHash', 'Make appointment', 'View package definition costs', #NEWNEW 'Change package start date', + 'Change package contract end date', ], ### @@ -183,6 +185,7 @@ tie my %rights, 'Tie::IxHash', 'Customer invoice / financial info rights' => [ 'View invoices', 'Resend invoices', #NEWNEW + { rightname=>'Print and mail invoices', desc=>"Print and mail via Freeside's web service", }, ##NEWER than NEWNEWNEW 'Void invoices', 'Unvoid invoices', 'View customer tax exemptions', #yow diff --git a/FS/FS/ClientAPI/Freeside.pm b/FS/FS/ClientAPI/Freeside.pm new file mode 100644 index 000000000..8aa61e632 --- /dev/null +++ b/FS/FS/ClientAPI/Freeside.pm @@ -0,0 +1,66 @@ +package FS::ClientAPI::Freeside; + +use strict; +#use vars qw($DEBUG $me); +use FS::Record qw(qsearchs); +use FS::Conf; +use FS::svc_acct; +use FS::webservice_log; + +#$DEBUG = 0; +#$me = '[FS::ClientAPI:Freeside]'; + +# inputs: +# support-key +# method +# quantity (i.e. pages) - defaults to 1 +# +# returns: +# error (empty, or error message) +# custnum + +sub freesideinc_service { + my $packet = shift; + + my $svcpart = FS::Conf->new->config('freesideinc-webservice-svcpart') + or return { 'error' => 'guru meditation #pow' }; + die 'no' unless $svcpart =~ /^\d+$/; + + ( my $support_key = $packet->{'support-key'} ) =~ /^\s*([^:]+):(.+)\s*$/ + or return { 'error' => 'bad support-key' }; + my($username, $_password) = ($1,$2); + + my $svc_acct = qsearchs({ + 'table' => 'svc_acct', + 'addl_from' => 'LEFT JOIN cust_svc USING ( svcnum )', + 'hashref' => { 'username' => $username, + '_password' => $_password, + }, + 'extra_sql' => "AND svcpart = $svcpart", + }); + unless ( $svc_acct ) { + warn "bad support-key for $username from $ENV{REMOTE_IP}\n"; + sleep 5; #ideally also rate-limit and eventually ban their IP + return { 'error' => 'bad support-key' }; + } + + #XXX check if some customers can use some API calls, rate-limiting, etc. + # but for now, everybody can use everything + + #record it happened + my $custnum = $svc_acct->cust_svc->cust_pkg->custnum; + my $webservice_log = new FS::webservice_log { + 'custnum' => $custnum, + 'svcnum' => $svc_acct->svcnum, + 'method' => $packet->{'method'}, + 'quantity' => $packet->{'quantity'} || 1, + }; + my $error = $webservice_log->insert; + return { 'error' => $error } if $error; + + return { 'error' => '', + 'custnum' => $custnum, + }; +} + +1; diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 6b91101d5..685821bad 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -11,7 +11,7 @@ use Digest::SHA qw(sha512_hex); use Date::Format; use Time::Duration; use Time::Local qw(timelocal_nocheck); -use Business::CreditCard; +use Business::CreditCard 0.35; use HTML::Entities; use Text::CSV_XS; use Spreadsheet::WriteExcel; @@ -1627,6 +1627,50 @@ 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; + + 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'} }; + + return { 'error' => $cust_payby->verify }; + +} + sub delete_payby { my $p = shift; @@ -1819,6 +1863,7 @@ sub list_svcs { # @svc_x; my @svcs; # stuff to return to the client + my %bytes_used_total; # for _used columns only foreach my $cust_svc (@cust_svc) { my $svc_x = $cust_svc->svc_x; my($label, $value) = $cust_svc->label; @@ -1840,6 +1885,24 @@ 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) + ); + $bytes_used_total{'seconds_used'} += $hash{'seconds_used'}; + $bytes_used_total{'upbytes_used'} += $up_used; + $bytes_used_total{'downbytes_used'} += $down_used; + $bytes_used_total{'totalbytes_used'} += $up_used + $down_used; + } + if ( $svcdb eq 'svc_acct' ) { foreach (qw(username email finger seconds)) { $hash{$_} = $svc_x->$_; @@ -1912,12 +1975,19 @@ sub list_svcs { push @svcs, \%hash; } # foreach $cust_svc + foreach my $field (keys %bytes_used_total) { + if ($field =~ /bytes/) { + $bytes_used_total{$field} = display_bytecount($bytes_used_total{$field}); + } + } + return { 'svcnum' => $session->{'svcnum'}, 'custnum' => $custnum, 'date_format' => $conf->config('date_format') || '%m/%d/%Y', 'view_usage_nodomain' => $conf->exists('selfservice-view_usage_nodomain'), 'svcs' => \@svcs, + 'bytes_used_total' => \%bytes_used_total, 'usage_pools' => [ map { $usage_pools{$_} } sort { $a cmp $b } @@ -2410,7 +2480,7 @@ sub order_pkg { my $conf = new FS::Conf; if ( $conf->exists('signup_server-realtime') ) { - my $bill_error = _do_bop_realtime( $cust_main, $status ); + my $bill_error = _do_bop_realtime( $cust_main, $status, 'collect'=>$p->{run_bill_events} ); if ($bill_error) { $cust_pkg->cancel('quiet'=>1); @@ -2565,6 +2635,12 @@ sub _do_bop_realtime { return { 'error' => '_decline', 'bill_error' => $bill_error }; } + if ( $opt{'collect'} ) { + my $collect_error = $cust_main->collect(); + return { 'error' => '_decline', 'bill_error' => $collect_error } + if $collect_error; #? + } + ''; } @@ -2671,19 +2747,18 @@ sub cancel_pkg { or return { 'error' => "Can't resume session" }; #better error message my $custnum = $session->{'custnum'}; - my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) or return { 'error' => "unknown custnum $custnum" }; my $pkgnum = $p->{'pkgnum'}; - my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum, 'pkgnum' => $pkgnum, } ) or return { 'error' => "unknown pkgnum $pkgnum" }; - my $error = $cust_pkg->cancel('quiet' => 1); + my $error = $cust_pkg->cancel( 'quiet' => 1, + 'date' => $p->{'date'}, + ); return { 'error' => $error }; - } sub provision_phone { diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm index df9ee88e5..df276f049 100644 --- a/FS/FS/ClientAPI/Signup.pm +++ b/FS/FS/ClientAPI/Signup.pm @@ -7,7 +7,7 @@ use Data::Dumper; use Tie::RefHash; use Digest::SHA qw(sha512_hex); use FS::Conf; -use FS::Record qw(qsearch qsearchs dbdef); +use FS::Record qw(qsearch qsearchs dbdef dbh); use FS::CGI qw(popurl); use FS::Msgcat qw(gettext); use FS::Misc qw(card_types); @@ -31,6 +31,25 @@ use FS::cust_payby; $DEBUG = 1; $me = '[FS::ClientAPI::Signup]'; +=head1 NAME + +FS::ClientAPI::Signup - Front-end API for signing up customers + +=head1 DESCRIPTION + +This module provides the ClientAPI functions for talking to a signup +server. The signup server is open to the public, i.e. does not require a +login. The back-end Freeside server creates customers, orders packages and +services, and processes initial payments. + +=head1 METHODS + +=over 4 + +=cut + +# document the rest of this as we work on it + sub clear_cache { warn "$me clear_cache called\n" if $DEBUG; my $cache = new FS::ClientAPI_SessionCache( { @@ -145,8 +164,6 @@ sub signup_info { 'security_phrase' => $conf->exists('security_phrase'), - 'nomadix' => $conf->exists('signup_server-nomadix'), - 'payby' => [ $conf->config('signup_server-payby') ], 'payby_longname' => [ map { FS::payby->longname($_) } @@ -173,7 +190,6 @@ sub signup_info { 'agentnum2part_pkg' => $agentnum2part_pkg, 'svc_acct_pop' => [ map $_->hashref, qsearch('svc_acct_pop',{} ) ], - 'nomadix' => $conf->exists('signup_server-nomadix'), 'payby' => [ $conf->config('signup_server-payby') ], 'card_types' => card_types(), 'paytypes' => [ FS::cust_payby->paytypes ], @@ -499,21 +515,8 @@ sub new_customer { #possibly some validation will be needed } - my $agentnum; - if ( exists $packet->{'session_id'} ) { - my $cache = new FS::ClientAPI_SessionCache( { - 'namespace' => 'FS::ClientAPI::Agent', - } ); - my $session = $cache->get($packet->{'session_id'}); - if ( $session ) { - $agentnum = $session->{'agentnum'}; - } else { - return { 'error' => "Can't resume session" }; #better error message - } - } else { - $agentnum = $packet->{agentnum} - || $conf->config('signup_server-default_agentnum'); - } + my $agentnum = get_agentnum($packet); + return $agentnum if ref($agentnum); my ($bill_hash, $ship_hash); foreach my $f (FS::cust_main->location_fields) { @@ -924,21 +927,8 @@ sub new_customer_minimal { #possibly some validation will be needed } - my $agentnum; - if ( exists $packet->{'session_id'} ) { - my $cache = new FS::ClientAPI_SessionCache( { - 'namespace' => 'FS::ClientAPI::Agent', - } ); - my $session = $cache->get($packet->{'session_id'}); - if ( $session ) { - $agentnum = $session->{'agentnum'}; - } else { - return { 'error' => "Can't resume session" }; #better error message - } - } else { - $agentnum = $packet->{agentnum} - || $conf->config('signup_server-default_agentnum'); - } + my $agentnum = get_agentnum($packet); + return $agentnum if ref($agentnum); #shares some stuff with htdocs/edit/process/cust_main.cgi... take any # common that are still here and library them. @@ -1220,4 +1210,186 @@ sub capture_payment { } +=item get_agentnum PACKET + +Given a PACKET from the signup server, looks up the agentnum to use for signing +up a customer. This will use 'session_id' if the agent is authenticated, +otherwise 'agentnum', otherwise the 'signup_server-default_agentnum' config. If +the agent can't be found, returns an error packet. + +=cut + +sub get_agentnum { + my $packet = shift; + my $conf = new FS::Conf; + my $agentnum; + if ( exists $packet->{'session_id'} ) { + my $cache = new FS::ClientAPI_SessionCache( { + 'namespace' => 'FS::ClientAPI::Agent', + } ); + my $session = $cache->get($packet->{'session_id'}); + if ( $session ) { + $agentnum = $session->{'agentnum'}; + } else { + return { 'error' => "Can't resume session" }; #better error message + } + } else { + $agentnum = $packet->{agentnum} + || $conf->config('signup_server-default_agentnum'); + } + if ( $agentnum and FS::agent->count('agentnum = ?', $agentnum) ) { + return $agentnum; + } + return { 'error' => 'Signup is not configured' }; +} + +=item new_prospect PACKET + +Creates a new L<FS::prospect_main> entry. PACKET must contain: + +- either agentnum or session_id; if not, signup_server-default_agentnum will +be used and must not be empty + +- either refnum or referral_title; if not, signup_server-default_refnum will +be used and must not be empty + +- last and first (names), and optionally company and title + +- address1, city, state, country, zip, and optionally address2 + +- emailaddress + +and can also contain: + +- one or more of phone_daytime, phone_night, phone_mobile, and phone_fax + +- a 'comment' (will be attached to the contact) + +State and country will be normalized to Freeside state/country codes if +necessary. + +=cut + +sub new_prospect { + + my $packet = shift; + warn "$me new_prospect called\n".Dumper($packet) if $DEBUG; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + my $conf = FS::Conf->new; + + my $error; + + my $agentnum = get_agentnum($packet); + return $agentnum if ref $agentnum; + my $refnum; + if ( my $title = $packet->{referral_title} ) { + my $part_referral = qsearchs('part_referral', { + 'agentnum' => $agentnum, + 'title' => $title, + }); + $part_referral ||= qsearchs('part_referral', { + 'agentnum' => '', + 'title' => $title, + }); + if (!$part_referral) { + return { error => "Unknown referral type: '$title'" }; + } + $refnum = $part_referral->refnum; + } elsif ( $packet->{refnum} ) { + $refnum = $packet->{refnum}; + } + $refnum ||= $conf->config('signup_server-default_refnum'); + return { error => "Signup referral type is not configured" } if !$refnum; + + my $prospect = FS::prospect_main->new({ + 'agentnum' => $agentnum, + 'refnum' => $refnum, + 'company' => $packet->{company}, + }); + + my $location = FS::cust_location->new; + foreach ( qw(address1 address2 city county zip ) ) { + $location->set($_, $packet->{$_}); + } + # normalize country and state if they're not already ISO codes + # easier than doing it on the client side--we already have the tables here + my $country = $packet->{country}; + my $state = $packet->{state}; + if (length($country) > 2) { + # it likes title case + $country = join(' ', map ucfirst, split(/\s+/, $country)); + my $lsc = Locale::SubCountry->new($country); + if ($lsc) { + $country = uc($lsc->country_code); + + if ($lsc->has_sub_countries) { + if ( $lsc->full_name($state) eq 'unknown' ) { + # then we were probably given a full name, so resolve it + $state = $lsc->code($state); + if ( $state eq 'unknown' ) { + # doesn't resolve as a full name either, return an error + $error = "Unknown state: ".$packet->{state}; + } else { + $state = uc($state); + } + } + } # else state doesn't matter + } else { + # couldn't find the country in LSC + $error = "Unknown country: $country"; + } + } + $location->set('country', $country); + $location->set('state', $state); + $prospect->set('cust_location', $location); + + $error ||= $prospect->insert; # also does location + return { error => $error } if $error; + + my $contact = FS::contact->new({ + prospectnum => $prospect->prospectnum, + locationnum => $location->locationnum, + invoice_dest => 'Y', + }); + # use emailaddress pseudo-field behavior here + foreach (qw(last first title emailaddress comment)) { + $contact->set($_, $packet->{$_}); + } + $error = $contact->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return { error => $error }; + } + + foreach my $phone_type (qsearch('phone_type', {})) { + my $key = 'phone_' . lc($phone_type->typename); + my $phonenum = $packet->{$key}; + if ( $phonenum ) { + # just to not have to supply country code from the other end + my $number = Number::Phone->new($location->country, $phonenum); + if (!$number) { + $error = 'invalid phone number'; + } else { + my $phone = FS::contact_phone->new({ + contactnum => $contact->contactnum, + phonenum => $phonenum, + countrycode => $number->country_code, + phonetypenum => $phone_type->phonetypenum, + }); + $error = $phone->insert; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return { error => $phone_type->typename . ' phone: ' . $error }; + } + } + } # foreach $phone_type + + $dbh->commit if $oldAutoCommit; + return { prospectnum => $prospect->prospectnum }; +} + 1; diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm index 91f979d1d..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', @@ -192,6 +193,7 @@ sub ss2clientapi { 'new_customer_minimal' => 'Signup/new_customer_minimal', 'capture_payment' => 'Signup/capture_payment', 'clear_signup_cache' => 'Signup/clear_cache', + 'new_prospect' => 'Signup/new_prospect', 'new_agent' => 'Agent/new_agent', 'agent_login' => 'Agent/agent_login', 'agent_logout' => 'Agent/agent_logout', @@ -213,6 +215,8 @@ sub ss2clientapi { 'quotation_add_pkg' => 'MyAccount/quotation/quotation_add_pkg', 'quotation_remove_pkg' => 'MyAccount/quotation/quotation_remove_pkg', 'quotation_order' => 'MyAccount/quotation/quotation_order', + + 'freesideinc_service' => 'Freeside/freesideinc_service', }; } diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 90bb3b143..9f1a7072b 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -1,7 +1,8 @@ package FS::Conf; use strict; -use vars qw( $base_dir @config_items @base_items @card_types $DEBUG +use vars qw( $base_dir @config_items @base_items @card_types @invoice_terms + $DEBUG $conf_cache $conf_cache_enabled ); use Carp; @@ -510,7 +511,7 @@ sub _orbase_items { 'key' => $_, 'base_key' => $proto->key, 'section' => $proto->section, - 'description' => 'Alternate ' . $proto->description . ' See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#Invoice_templates">billing documentation</a> for details.', + 'description' => 'Alternate ' . $proto->description . ' See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Administration#Invoice_templates">billing documentation</a> for details.', 'type' => $proto->type, }; } &$listmaker($base); @@ -533,17 +534,18 @@ sub config_items { =item invoice_from_full [ AGENTNUM ] -Returns values of invoice_from and invoice_from_name, appropriately combined -based on their current values. +Returns values of invoice_from and invoice_from_name (or, if that is not +defined, company_name), appropriately combined based on their current values. =cut sub invoice_from_full { my ($self, $agentnum) = @_; - return $self->config('invoice_from_name', $agentnum ) ? - $self->config('invoice_from_name', $agentnum ) . ' <' . - $self->config('invoice_from', $agentnum ) . '>' : - $self->config('invoice_from', $agentnum ); + + ( $self->config('invoice_from_name', $agentnum) + || $self->config('company_name', $agentnum) + ). + ' <'. $self->config('invoice_from', $agentnum ). '>'; } =back @@ -615,6 +617,14 @@ logo.png logo.eps ); +@invoice_terms = ( + '', + 'Payable upon receipt', + 'Net 0', 'Net 3', 'Net 5', 'Net 7', 'Net 9', 'Net 10', 'Net 14', + 'Net 15', 'Net 18', 'Net 20', 'Net 21', 'Net 25', 'Net 30', 'Net 45', + 'Net 60', 'Net 90' +); + my %msg_template_options = ( 'type' => 'select-sub', 'options_sub' => sub { @@ -736,31 +746,8 @@ my $validate_email = sub { $_[0] =~ }, { - 'key' => 'alert_expiration', - 'section' => 'deprecated', - 'description' => 'Enable alerts about credit card expiration. This is obsolete and no longer works.', - 'type' => 'checkbox', - 'per_agent' => 1, - }, - - { - 'key' => 'alerter_template', - 'section' => 'deprecated', - 'description' => 'Template file for billing method expiration alerts (i.e. expiring credit cards).', - 'type' => 'textarea', - 'per_agent' => 1, - }, - - { - 'key' => 'alerter_msgnum', - 'section' => 'deprecated', - 'description' => 'Template to use for credit card expiration alerts.', - %msg_template_options, - }, - - { 'key' => 'part_pkg-lineage', - 'section' => '', + 'section' => 'packages', 'description' => 'When editing a package definition, if setup or recur fees are changed, create a new package rather than changing the existing package.', 'type' => 'checkbox', }, @@ -770,7 +757,7 @@ my $validate_email = sub { $_[0] =~ #not actually deprecated yet #'section' => 'deprecated', #'description' => '<b>DEPRECATED</b>, add an <i>apache</i> <a href="../browse/part_export.cgi">export</a> instead. Used to be the current IP address to assign to new virtual hosts', - 'section' => '', + 'section' => 'services', 'description' => 'IP address to assign to new virtual hosts', 'type' => 'text', }, @@ -784,35 +771,35 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'credit-card-surcharge-percentage', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Add a credit card surcharge to invoices, as a % of the invoice total. WARNING: Although recently permitted to US merchants in general, specific consumer protection laws may prohibit or restrict this practice in California, Colorado, Connecticut, Florda, Kansas, Maine, Massachusetts, New York, Oklahome, and Texas. Surcharging is also generally prohibited in most countries outside the US, AU and UK. When allowed, typically not permitted to be above 4%.', 'type' => 'text', }, { 'key' => 'discount-show-always', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Generate a line item on an invoice even when a package is discounted 100%', 'type' => 'checkbox', }, { 'key' => 'discount-show_available', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Show available prepayment discounts on invoices.', 'type' => 'checkbox', }, { 'key' => 'invoice-barcode', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Display a barcode on HTML and PDF invoices', 'type' => 'checkbox', }, { 'key' => 'cust_main-select-billday', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'When used with a specific billing event, allows the selection of the day of month on which to charge credit card / bank account automatically, on a per-customer basis', 'type' => 'checkbox', }, @@ -833,14 +820,14 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'encryption', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Enable encryption of credit cards and echeck numbers', 'type' => 'checkbox', }, { 'key' => 'encryptionmodule', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Use which module for encryption?', 'type' => 'select', 'select_enum' => [ '', 'Crypt::OpenSSL::RSA', ], @@ -848,21 +835,21 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'encryptionpublickey', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Encryption public key', 'type' => 'textarea', }, { 'key' => 'encryptionprivatekey', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Encryption private key', 'type' => 'textarea', }, { 'key' => 'billco-url', - 'section' => 'billing', + 'section' => 'print_services', 'description' => 'The url to use for performing uploads to the invoice mailing service.', 'type' => 'text', 'per_agent' => 1, @@ -870,7 +857,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'billco-username', - 'section' => 'billing', + 'section' => 'print_services', 'description' => 'The login name to use for uploads to the invoice mailing service.', 'type' => 'text', 'per_agent' => 1, @@ -879,7 +866,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'billco-password', - 'section' => 'billing', + 'section' => 'print_services', 'description' => 'The password to use for uploads to the invoice mailing service.', 'type' => 'text', 'per_agent' => 1, @@ -888,7 +875,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'billco-clicode', - 'section' => 'billing', + 'section' => 'print_services', 'description' => 'The clicode to use for uploads to the invoice mailing service.', 'type' => 'text', 'per_agent' => 1, @@ -896,7 +883,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'billco-account_num', - 'section' => 'billing', + 'section' => 'print_services', 'description' => 'The data to place in the "Transaction Account No" / "TRACCTNUM" field.', 'type' => 'select', 'select_hash' => [ @@ -915,21 +902,21 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'business-onlinepayment', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => '<a href="http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment">Business::OnlinePayment</a> support, at least three lines: processor, login, and password. An optional fourth line specifies the action or actions (multiple actions are separated with `,\': for example: `Authorization Only, Post Authorization\'). Optional additional lines are passed to Business::OnlinePayment as %processor_options. For more detailed information and examples see the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Administration:Real-time_Processing">real-time credit card processing documentation</a>.', 'type' => 'textarea', }, { 'key' => 'business-onlinepayment-ach', - 'section' => 'billing', + 'section' => 'e-checks', 'description' => 'Alternate <a href="http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment">Business::OnlinePayment</a> support for ACH transactions (defaults to regular <b>business-onlinepayment</b>). At least three lines: processor, login, and password. An optional fourth line specifies the action or actions (multiple actions are separated with `,\': for example: `Authorization Only, Post Authorization\'). Optional additional lines are passed to Business::OnlinePayment as %processor_options.', 'type' => 'textarea', }, { 'key' => 'business-onlinepayment-namespace', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Specifies which perl module namespace (which group of collection routines) is used by default.', 'type' => 'select', 'select_hash' => [ @@ -940,43 +927,50 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'business-onlinepayment-description', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'String passed as the description field to <a href="http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment">Business::OnlinePayment</a>. Evaluated as a double-quoted perl string, with the following variables available: <code>$agent</code> (the agent name), and <code>$pkgs</code> (a comma-separated list of packages for which these charges apply - not available in all situations)', 'type' => 'text', }, { 'key' => 'business-onlinepayment-email-override', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Email address used instead of customer email address when submitting a BOP transaction.', 'type' => 'text', }, { 'key' => 'business-onlinepayment-email_customer', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Controls the "email_customer" flag used by some Business::OnlinePayment processors to enable customer receipts.', 'type' => 'checkbox', }, { 'key' => 'business-onlinepayment-test_transaction', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Turns on the Business::OnlinePayment test_transaction flag. Note that not all gateway modules support this flag; if yours does not, transactions will still be sent live.', 'type' => 'checkbox', }, { 'key' => 'business-onlinepayment-currency', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Currency parameter for Business::OnlinePayment transactions.', 'type' => 'select', 'select_enum' => [ '', qw( USD AUD CAD DKK EUR GBP ILS JPY NZD ARS ) ], }, { + 'key' => 'business-onlinepayment-verification', + 'section' => 'credit_cards', + 'description' => 'Run a $1 authorization (followed by a void) to verify new credit card information.', + 'type' => 'checkbox', + }, + + { 'key' => 'currency', - 'section' => 'billing', + 'section' => 'localization', 'description' => 'Main accounting currency', 'type' => 'select', 'select_enum' => [ '', qw( USD AUD CAD DKK EUR GBP ILS JPY NZD XAF ARS ) ], @@ -984,7 +978,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'currencies', - 'section' => 'billing', + 'section' => 'localization', 'description' => 'Additional accepted currencies', 'type' => 'select-sub', 'multiple' => 1, @@ -997,21 +991,21 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'business-batchpayment-test_transaction', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Turns on the Business::BatchPayment test_mode flag. Note that not all gateway modules support this flag; if yours does not, using the batch gateway will fail.', 'type' => 'checkbox', }, { 'key' => 'countrydefault', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Default two-letter country code (if not supplied, the default is `US\')', 'type' => 'text', }, { 'key' => 'date_format', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Format for displaying dates', 'type' => 'select', 'select_hash' => [ @@ -1025,7 +1019,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'date_format_long', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Verbose format for displaying dates', 'type' => 'select', 'select_hash' => [ @@ -1054,35 +1048,35 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'disable_cust_attachment', - 'section' => '', + 'section' => 'notes', 'description' => 'Disable customer file attachments', 'type' => 'checkbox', }, { 'key' => 'max_attachment_size', - 'section' => '', + 'section' => 'notes', 'description' => 'Maximum size for customer file attachments (leave blank for unlimited)', 'type' => 'text', }, { 'key' => 'disable_customer_referrals', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Disable new customer-to-customer referrals in the web interface', 'type' => 'checkbox', }, { 'key' => 'editreferrals', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Enable advertising source modification for existing customers', 'type' => 'checkbox', }, { 'key' => 'emailinvoiceonly', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Disables postal mail invoices', 'type' => 'checkbox', }, @@ -1096,56 +1090,56 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'emailinvoiceauto', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Automatically adds new accounts to the email invoice list', 'type' => 'checkbox', }, { 'key' => 'emailinvoiceautoalways', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Automatically adds new accounts to the email invoice list even when the list contains email addresses', 'type' => 'checkbox', }, { 'key' => 'emailinvoice-apostrophe', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Allows the apostrophe (single quote) character in the email addresses in the email invoice list.', 'type' => 'checkbox', }, { 'key' => 'svc_acct-ip_addr', - 'section' => '', + 'section' => 'services', 'description' => 'Enable IP address management on login services like for broadband services.', 'type' => 'checkbox', }, { 'key' => 'exclude_ip_addr', - 'section' => '', - 'description' => 'Exclude these from the list of available broadband service IP addresses. (One per line)', + 'section' => 'services', + 'description' => 'Exclude these from the list of available IP addresses. (One per line)', 'type' => 'textarea', }, { 'key' => 'auto_router', - 'section' => '', + 'section' => 'wireless_broadband', 'description' => 'Automatically choose the correct router/block based on supplied ip address when possible while provisioning broadband services', 'type' => 'checkbox', }, { 'key' => 'hidecancelledpackages', - 'section' => 'UI', + 'section' => 'cancellation', 'description' => 'Prevent cancelled packages from showing up in listings (though they will still be in the database)', 'type' => 'checkbox', }, { 'key' => 'hidecancelledcustomers', - 'section' => 'UI', + 'section' => 'cancellation', 'description' => 'Prevent customers with only cancelled packages from showing up in listings (though they will still be in the database)', 'type' => 'checkbox', }, @@ -1159,8 +1153,8 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_from', - 'section' => 'required', - 'description' => 'Return address on email invoices (address only, see invoice_from_name)', + 'section' => 'important', + 'description' => 'Return address on email invoices ("user@domain" only)', 'type' => 'text', 'per_agent' => 1, 'validate' => $validate_email, @@ -1168,7 +1162,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_from_name', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Return name on email invoices (set address in invoice_from)', 'type' => 'text', 'per_agent' => 1, @@ -1179,7 +1173,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'quotation_from', - 'section' => '', + 'section' => 'quotations', 'description' => 'Return address on email quotations', 'type' => 'text', 'per_agent' => 1, @@ -1188,7 +1182,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_subject', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Subject: header on email invoices. Defaults to "Invoice". The following substitutions are available: $name, $name_short, $invoice_number, and $invoice_date.', 'type' => 'text', 'per_agent' => 1, @@ -1197,7 +1191,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'quotation_subject', - 'section' => '', + 'section' => 'quotations', 'description' => 'Subject: header on email quotations. Defaults to "Quotation".', # The following substitutions are available: $name, $name_short, $invoice_number, and $invoice_date.', 'type' => 'text', #'per_agent' => 1, @@ -1213,22 +1207,22 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_template', - 'section' => 'invoicing', - 'description' => 'Text template file for invoices. Used if no invoice_html template is defined, and also seen by users using non-HTML capable mail clients. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#Plaintext_invoice_templates">billing documentation</a> for details.', + 'section' => 'invoice_templates', + 'description' => 'Text template file for invoices. Used if no invoice_html template is defined, and also seen by users using non-HTML capable mail clients. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Administration#Plaintext_invoice_templates">billing documentation</a> for details.', 'type' => 'textarea', }, { 'key' => 'invoice_html', - 'section' => 'invoicing', - 'description' => 'HTML template for invoices. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#HTML_invoice_templates">billing documentation</a> for details.', + 'section' => 'invoice_templates', + 'description' => 'HTML template for invoices. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Administration#HTML_invoice_templates">billing documentation</a> for details.', 'type' => 'textarea', }, { 'key' => 'quotation_html', - 'section' => '', + 'section' => 'quotations', 'description' => 'HTML template for quotations.', 'type' => 'textarea', @@ -1236,7 +1230,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_htmlnotes', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Notes section for HTML invoices. Defaults to the same data in invoice_latexnotes if not specified.', 'type' => 'textarea', 'per_agent' => 1, @@ -1245,7 +1239,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_htmlfooter', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Footer for HTML invoices. Defaults to the same data in invoice_latexfooter if not specified.', 'type' => 'textarea', 'per_agent' => 1, @@ -1254,7 +1248,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_htmlsummary', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Summary initial page for HTML invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1263,7 +1257,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_htmlreturnaddress', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Return address for HTML invoices. Defaults to the same data in invoice_latexreturnaddress if not specified.', 'type' => 'textarea', 'per_locale' => 1, @@ -1271,7 +1265,7 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_htmlwatermark', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Watermark for HTML invoices. Appears in a semitransparent positioned DIV overlaid on the main invoice container.', 'type' => 'textarea', 'per_agent' => 1, @@ -1280,14 +1274,14 @@ my $validate_email = sub { $_[0] =~ { 'key' => 'invoice_latex', - 'section' => 'invoicing', - 'description' => 'Optional LaTeX template for typeset PostScript invoices. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#Typeset_.28LaTeX.29_invoice_templates">billing documentation</a> for details.', + 'section' => 'invoice_templates', + 'description' => 'Optional LaTeX template for typeset PostScript invoices. See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Administration#Typeset_.28LaTeX.29_invoice_templates">billing documentation</a> for details.', 'type' => 'textarea', }, { 'key' => 'quotation_latex', - 'section' => '', + 'section' => 'quotations', 'description' => 'LaTeX template for typeset PostScript quotations.', 'type' => 'textarea', }, @@ -1343,7 +1337,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexnotes', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Notes section for LaTeX typeset PostScript invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1352,7 +1346,7 @@ and customer address. Include units.', { 'key' => 'quotation_latexnotes', - 'section' => '', + 'section' => 'quotations', 'description' => 'Notes section for LaTeX typeset PostScript quotations.', 'type' => 'textarea', 'per_agent' => 1, @@ -1361,7 +1355,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexfooter', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Footer for LaTeX typeset PostScript invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1370,7 +1364,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexsummary', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Summary initial page for LaTeX typeset PostScript invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1379,7 +1373,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexcoupon', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Remittance coupon for LaTeX typeset PostScript invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1389,7 +1383,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexextracouponspace', 'section' => 'invoicing', - 'description' => 'Optional LaTeX invoice textheight space to reserve for a tear off coupon. Include units. Default is 3.6cm', + 'description' => 'Optional LaTeX invoice textheight space to reserve for a tear off coupon. Include units. Default is 2.7 inches.', 'type' => 'text', 'per_agent' => 1, 'validate' => sub { shift =~ @@ -1401,7 +1395,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexcouponfootsep', 'section' => 'invoicing', - 'description' => 'Optional LaTeX invoice separation between tear off coupon and footer. Include units.', + 'description' => 'Optional LaTeX invoice separation between bottom of coupon address and footer. Include units. Default is 0.2 inches.', 'type' => 'text', 'per_agent' => 1, 'validate' => sub { shift =~ @@ -1413,7 +1407,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexcouponamountenclosedsep', 'section' => 'invoicing', - 'description' => 'Optional LaTeX invoice separation between total due and amount enclosed line. Include units.', + 'description' => 'Optional LaTeX invoice separation between total due and amount enclosed line. Include units. Default is 2.25 em.', 'type' => 'text', 'per_agent' => 1, 'validate' => sub { shift =~ @@ -1424,7 +1418,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexcoupontoaddresssep', 'section' => 'invoicing', - 'description' => 'Optional LaTeX invoice separation between invoice data and the to address (usually invoice_latexreturnaddress). Include units.', + 'description' => 'Optional LaTeX invoice separation between invoice data and the address (usually invoice_latexreturnaddress). Include units. Default is 1 inch.', 'type' => 'text', 'per_agent' => 1, 'validate' => sub { shift =~ @@ -1435,15 +1429,15 @@ and customer address. Include units.', { 'key' => 'invoice_latexreturnaddress', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Return address for LaTeX typeset PostScript invoices.', 'type' => 'textarea', }, { 'key' => 'invoice_latexverticalreturnaddress', - 'section' => 'invoicing', - 'description' => 'Place the return address under the company logo rather than beside it.', + 'section' => 'deprecated', + 'description' => 'Deprecated. With old invoice_latex template, places the return address under the company logo rather than beside it.', 'type' => 'checkbox', 'per_agent' => 1, }, @@ -1458,7 +1452,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexsmallfooter', - 'section' => 'invoicing', + 'section' => 'invoice_templates', 'description' => 'Optional small footer for multi-page LaTeX typeset PostScript invoices.', 'type' => 'textarea', 'per_agent' => 1, @@ -1467,7 +1461,7 @@ and customer address. Include units.', { 'key' => 'invoice_latexwatermark', - 'section' => 'invoicing', + 'section' => 'invocie_templates', 'description' => 'Watermark for LaTeX invoices. See "texdoc background" for information on what this can contain. The content itself should be enclosed in braces, optionally followed by a comma and any formatting options.', 'type' => 'textarea', 'per_agent' => 1, @@ -1476,56 +1470,56 @@ and customer address. Include units.', { 'key' => 'invoice_email_pdf', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Send PDF invoice as an attachment to emailed invoices. By default, includes the HTML invoice as the email body, unless invoice_email_pdf_note is set.', 'type' => 'checkbox' }, { 'key' => 'quotation_email_pdf', - 'section' => '', + 'section' => 'quotations', 'description' => 'Send PDF quotations as an attachment to emailed quotations. By default, includes the HTML quotation as the email body, unless quotation_email_pdf_note is set.', 'type' => 'checkbox' }, { 'key' => 'invoice_email_pdf_msgnum', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'Message template to send as the text and HTML part of PDF invoices. If not selected, a text and HTML version of the invoice will be sent.', %msg_template_options, }, { 'key' => 'invoice_email_pdf_note', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'If defined, this text will replace the default HTML invoice as the body of emailed PDF invoices.', 'type' => 'textarea' }, { 'key' => 'quotation_email_pdf_note', - 'section' => '', + 'section' => 'quotations', 'description' => 'If defined, this text will replace the default HTML quotation as the body of emailed PDF quotations.', 'type' => 'textarea' }, { 'key' => 'invoice_print_pdf', - 'section' => 'invoicing', + 'section' => 'printing', 'description' => 'For all invoice print operations, store postal invoices for download in PDF format rather than printing them directly.', 'type' => 'checkbox', }, { 'key' => 'invoice_print_pdf-spoolagent', - 'section' => 'invoicing', + 'section' => 'printing', 'description' => 'Store postal invoices PDF downloads in per-agent spools.', 'type' => 'checkbox', }, { 'key' => 'invoice_print_pdf-duplex', - 'section' => 'invoicing', + 'section' => 'printing', 'description' => 'Insert blank pages so that spooled invoices are each an even number of pages. Use this for double-sided printing.', 'type' => 'checkbox', }, @@ -1536,15 +1530,12 @@ and customer address. Include units.', 'description' => 'Optional default invoice term, used to calculate a due date printed on invoices.', 'type' => 'select', 'per_agent' => 1, - 'select_enum' => [ - '', 'Payable upon receipt', 'Net 0', 'Net 3', 'Net 5', 'Net 7', 'Net 9', 'Net 10', 'Net 14', - 'Net 15', 'Net 18', 'Net 20', 'Net 21', 'Net 25', 'Net 30', 'Net 45', - 'Net 60', 'Net 90' - ], }, + 'select_enum' => \@invoice_terms, + }, { 'key' => 'invoice_show_prior_due_date', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show previous invoice due dates when showing prior balances. Default is to show invoice date.', 'type' => 'checkbox', }, @@ -1559,7 +1550,7 @@ and customer address. Include units.', { 'key' => 'invoice_include_aging', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show an aging line after the prior balance section. Only valid when invoice_sections is enabled.', 'type' => 'checkbox', }, @@ -1591,29 +1582,29 @@ and customer address. Include units.', { 'key' => 'usage_class_summary', - 'section' => 'invoicing', - 'description' => 'Summarize total usage by usage class in a separate section.', + 'section' => 'telephony', + 'description' => 'On invoices, summarize total usage by usage class in a separate section', 'type' => 'checkbox', }, { 'key' => 'usage_class_as_a_section', - 'section' => 'invoicing', - 'description' => 'Split usage into sections and label according to usage class name when enabled. Only valid when invoice_sections is enabled.', + 'section' => 'telephony', + 'description' => 'On invoices, split usage into sections and label according to usage class name when enabled. Only valid when invoice_sections is enabled.', 'type' => 'checkbox', }, { 'key' => 'phone_usage_class_summary', - 'section' => 'invoicing', - 'description' => 'Summarize usage per DID by usage class and display all CDRs together regardless of usage class. Only valid when svc_phone_sections is enabled.', + 'section' => 'telephony', + 'description' => 'On invoices, summarize usage per DID by usage class and display all CDRs together regardless of usage class. Only valid when svc_phone_sections is enabled.', 'type' => 'checkbox', }, { 'key' => 'svc_phone_sections', - 'section' => 'invoicing', - 'description' => 'Create a section for each svc_phone when enabled. Only valid when invoice_sections is enabled.', + 'section' => 'telephony', + 'description' => 'On invoices, create a section for each svc_phone when enabled. Only valid when invoice_sections is enabled.', 'type' => 'checkbox', }, @@ -1626,8 +1617,8 @@ and customer address. Include units.', { 'key' => 'separate_usage', - 'section' => 'invoicing', - 'description' => 'Split the rated call usage into a separate line from the recurring charges.', + 'section' => 'telephony', + 'description' => 'On invoices, split the rated call usage into a separate line from the recurring charges.', 'type' => 'checkbox', }, @@ -1683,14 +1674,14 @@ and customer address. Include units.', { 'key' => 'trigger_export_insert_on_payment', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'Enable exports on payment application.', 'type' => 'checkbox', }, { 'key' => 'lpr', - 'section' => 'required', + 'section' => 'printing', 'description' => 'Print command for paper invoices, for example `lpr -h\'', 'type' => 'text', 'per_agent' => 1, @@ -1698,21 +1689,21 @@ and customer address. Include units.', { 'key' => 'lpr-postscript_prefix', - 'section' => 'billing', + 'section' => 'printing', 'description' => 'Raw printer commands prepended to the beginning of postscript print jobs (evaluated as a double-quoted perl string - backslash escapes are available)', 'type' => 'text', }, { 'key' => 'lpr-postscript_suffix', - 'section' => 'billing', + 'section' => 'printing', 'description' => 'Raw printer commands added to the end of postscript print jobs (evaluated as a double-quoted perl string - backslash escapes are available)', 'type' => 'text', }, { 'key' => 'papersize', - 'section' => 'billing', + 'section' => 'printing', 'description' => 'Invoice paper size. Default is "letter" (U.S. standard). The LaTeX template must be configured to match this size.', 'type' => 'select', 'select_enum' => [ qw(letter a4) ], @@ -1720,7 +1711,7 @@ and customer address. Include units.', { 'key' => 'money_char', - 'section' => '', + 'section' => 'localization', 'description' => 'Currency symbol - defaults to `$\'', 'type' => 'text', }, @@ -1804,7 +1795,7 @@ and customer address. Include units.', { 'key' => 'referraldefault', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Default referral, specified by refnum', 'type' => 'select-sub', 'options_sub' => sub { require FS::Record; @@ -1822,37 +1813,31 @@ and customer address. Include units.', }, }, -# { -# 'key' => 'registries', -# 'section' => 'required', -# 'description' => 'Directory which contains domain registry information. Each registry is a directory.', -# }, - { 'key' => 'maxsearchrecordsperpage', - 'section' => 'UI', + 'section' => 'reporting', 'description' => 'If set, number of search records to return per page.', 'type' => 'text', }, { 'key' => 'disable_maxselect', - 'section' => 'UI', + 'section' => 'reporting', 'description' => 'Prevent changing the number of records per page.', 'type' => 'checkbox', }, { 'key' => 'session-start', - 'section' => 'session', - 'description' => 'If defined, the command which is executed on the Freeside machine when a session begins. The contents of the file are treated as a double-quoted perl string, with the following variables available: <code>$ip</code>, <code>$nasip</code> and <code>$nasfqdn</code>, which are the IP address of the starting session, and the IP address and fully-qualified domain name of the NAS this session is on.', + 'section' => 'deprecated', + 'description' => 'Used to define the command which is executed on the Freeside machine when a session begins. The contents of the file are treated as a double-quoted perl string, with the following variables available: <code>$ip</code>, <code>$nasip</code> and <code>$nasfqdn</code>, which are the IP address of the starting session, and the IP address and fully-qualified domain name of the NAS this session is on.', 'type' => 'text', }, { 'key' => 'session-stop', - 'section' => 'session', - 'description' => 'If defined, the command which is executed on the Freeside machine when a session ends. The contents of the file are treated as a double-quoted perl string, with the following variables available: <code>$ip</code>, <code>$nasip</code> and <code>$nasfqdn</code>, which are the IP address of the starting session, and the IP address and fully-qualified domain name of the NAS this session is on.', + 'section' => 'deprecated', + 'description' => 'Used to define the command which is executed on the Freeside machine when a session ends. The contents of the file are treated as a double-quoted perl string, with the following variables available: <code>$ip</code>, <code>$nasip</code> and <code>$nasfqdn</code>, which are the IP address of the starting session, and the IP address and fully-qualified domain name of the NAS this session is on.', 'type' => 'text', }, @@ -1865,49 +1850,49 @@ and customer address. Include units.', { 'key' => 'showpasswords', - 'section' => 'UI', + 'section' => 'password', 'description' => 'Display unencrypted user passwords in the backend (employee) web interface', 'type' => 'checkbox', }, { 'key' => 'report-showpasswords', - 'section' => 'UI', + 'section' => 'password', 'description' => 'This is a terrible idea. Do not enable it. STRONGLY NOT RECOMMENDED. Enables display of passwords on services reports.', 'type' => 'checkbox', }, { 'key' => 'signupurl', - 'section' => 'UI', - 'description' => 'if you are using customer-to-customer referrals, and you enter the URL of your <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Self-Service_Installation">signup server CGI</a>, the customer view screen will display a customized link to the signup server with the appropriate customer as referral', + 'section' => 'signup', + 'description' => 'if you are using customer-to-customer referrals, and you enter the URL of your <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Self-Service_Installation">signup server CGI</a>, the customer view screen will display a customized link to self-signup with the appropriate customer as referral', 'type' => 'text', }, { 'key' => 'smtpmachine', - 'section' => 'required', + 'section' => 'important', 'description' => 'SMTP relay for Freeside\'s outgoing mail', 'type' => 'text', }, { 'key' => 'smtp-username', - 'section' => '', + 'section' => 'notification', 'description' => 'Optional SMTP username for Freeside\'s outgoing mail', 'type' => 'text', }, { 'key' => 'smtp-password', - 'section' => '', + 'section' => 'notification', 'description' => 'Optional SMTP password for Freeside\'s outgoing mail', 'type' => 'text', }, { 'key' => 'smtp-encryption', - 'section' => '', + 'section' => 'notification', 'description' => 'Optional SMTP encryption method. The STARTTLS methods require smtp-username and smtp-password to be set.', 'type' => 'select', 'select_hash' => [ '25' => 'None (port 25)', @@ -1961,24 +1946,24 @@ and customer address. Include units.', { 'key' => 'statedefault', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Default state or province (if not supplied, the default is `CA\')', 'type' => 'text', }, { 'key' => 'unsuspend_balance', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'Enables the automatic unsuspension of suspended packages when a customer\'s balance due is at or below the specified amount after a payment or credit', 'type' => 'select', 'select_enum' => [ - '', 'Zero', 'Latest invoice charges' + '', 'Zero', 'Latest invoice charges', 'Charges not past due' ], }, { 'key' => 'unsuspend-always_adjust_next_bill_date', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'Global override that causes unsuspensions to always adjust the next bill date under any circumstances. This is now controlled on a per-package bases - probably best not to use this option unless you are a legacy installation that requires this behaviour.', 'type' => 'checkbox', }, @@ -2078,35 +2063,35 @@ and customer address. Include units.', { 'key' => 'show_ship_company', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Turns on display/collection of a "service company name" field for customers.', 'type' => 'checkbox', }, { 'key' => 'show_ss', - 'section' => 'UI', + 'section' => 'e-checks', 'description' => 'Turns on display/collection of social security numbers in the web interface. Sometimes required by electronic check (ACH) processors.', 'type' => 'checkbox', }, { 'key' => 'unmask_ss', - 'section' => 'UI', + 'section' => 'e-checks', 'description' => "Don't mask social security numbers in the web interface.", 'type' => 'checkbox', }, { 'key' => 'show_stateid', - 'section' => 'UI', + 'section' => 'e-checks', 'description' => "Turns on display/collection of driver's license/state issued id numbers in the web interface. Sometimes required by electronic check (ACH) processors.", 'type' => 'checkbox', }, { 'key' => 'national_id-country', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Track a national identification number, for specific countries.', 'type' => 'select', 'select_enum' => [ '', 'MY' ], @@ -2114,14 +2099,14 @@ and customer address. Include units.', { 'key' => 'show_bankstate', - 'section' => 'UI', + 'section' => 'e-checks', 'description' => "Turns on display/collection of state for bank accounts in the web interface. Sometimes required by electronic check (ACH) processors.", 'type' => 'checkbox', }, { 'key' => 'agent_defaultpkg', - 'section' => 'UI', + 'section' => 'packages', 'description' => 'Setting this option will cause new packages to be available to all agent types by default.', 'type' => 'checkbox', }, @@ -2142,7 +2127,7 @@ and customer address. Include units.', { 'key' => 'queue_dangerous_controls', - 'section' => 'UI', + 'section' => 'development', 'description' => 'Enable queue modification controls on account pages and for new jobs. Unless you are a developer working on new export code, you should probably leave this off to avoid causing provisioning problems.', 'type' => 'checkbox', }, @@ -2156,7 +2141,7 @@ and customer address. Include units.', { 'key' => 'locale', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Default locale', 'type' => 'select-sub', 'options_sub' => sub { @@ -2169,8 +2154,8 @@ and customer address. Include units.', { 'key' => 'signup_server-payby', - 'section' => 'self-service', - 'description' => 'Acceptable payment types for the signup server', + 'section' => 'signup', + 'description' => 'Acceptable payment types for self-signup', 'type' => 'selectmultiple', 'select_enum' => [ qw(CARD DCRD CHEK DCHK PREPAY PPAL ) ], # BILL COMP) ], }, @@ -2191,22 +2176,22 @@ and customer address. Include units.', { 'key' => 'default_agentnum', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Default agent for the backoffice', 'type' => 'select-agent', }, { 'key' => 'signup_server-default_agentnum', - 'section' => 'self-service', - 'description' => 'Default agent for the signup server', + 'section' => 'signup', + 'description' => 'Default agent for self-signup', 'type' => 'select-agent', }, { 'key' => 'signup_server-default_refnum', - 'section' => 'self-service', - 'description' => 'Default advertising source for the signup server', + 'section' => 'signup', + 'description' => 'Default advertising source for self-signup', 'type' => 'select-sub', 'options_sub' => sub { require FS::Record; require FS::part_referral; @@ -2225,28 +2210,28 @@ and customer address. Include units.', { 'key' => 'signup_server-default_pkgpart', - 'section' => 'self-service', - 'description' => 'Default package for the signup server', + 'section' => 'signup', + 'description' => 'Default package for self-signup', 'type' => 'select-part_pkg', }, { 'key' => 'signup_server-default_svcpart', - 'section' => 'self-service', - 'description' => 'Default service definition for the signup server - only necessary for services that trigger special provisioning widgets (such as DID provisioning or domain selection).', + 'section' => 'signup', + 'description' => 'Default service definition for self-signup - only necessary for services that trigger special provisioning widgets (such as DID provisioning or domain selection).', 'type' => 'select-part_svc', }, { 'key' => 'signup_server-default_domsvc', - 'section' => 'self-service', - 'description' => 'If specified, the default domain svcpart for signup (useful when domain is set to selectable choice).', + 'section' => 'signup', + 'description' => 'If specified, the default domain svcpart for self-signup (useful when domain is set to selectable choice).', 'type' => 'text', }, { 'key' => 'signup_server-mac_addr_svcparts', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Service definitions which can receive mac addresses (current mapped to username for svc_acct).', 'type' => 'select-part_svc', 'multiple' => 1, @@ -2254,15 +2239,15 @@ and customer address. Include units.', { 'key' => 'signup_server-nomadix', - 'section' => 'self-service', + 'section' => 'deprecated', 'description' => 'Signup page Nomadix integration', 'type' => 'checkbox', }, { 'key' => 'signup_server-service', - 'section' => 'self-service', - 'description' => 'Service for the signup server - "Account (svc_acct)" is the default setting, or "Phone number (svc_phone)" for ITSP signup', + 'section' => 'signup', + 'description' => 'Service for the self-signup - "Account (svc_acct)" is the default setting, or "Phone number (svc_phone)" for ITSP signup', 'type' => 'select', 'select_hash' => [ 'svc_acct' => 'Account (svc_acct)', @@ -2274,15 +2259,15 @@ and customer address. Include units.', { 'key' => 'signup_server-prepaid-template-custnum', - 'section' => 'self-service', - 'description' => 'When the signup server is used with prepaid cards and customer info is not required for signup, the contact/address info will be copied from this customer, if specified', + 'section' => 'signup', + 'description' => 'When self-signup is used with prepaid cards and customer info is not required for signup, the contact/address info will be copied from this customer, if specified', 'type' => 'text', }, { 'key' => 'signup_server-terms_of_service', - 'section' => 'self-service', - 'description' => 'Terms of Service for the signup server. May contain HTML.', + 'section' => 'signup', + 'description' => 'Terms of Service for self-signup. May contain HTML.', 'type' => 'textarea', 'per_agent' => 1, }, @@ -2296,42 +2281,42 @@ and customer address. Include units.', { 'key' => 'show-msgcat-codes', - 'section' => 'UI', + 'section' => 'development', 'description' => 'Show msgcat codes in error messages. Turn this option on before reporting errors to the mailing list.', 'type' => 'checkbox', }, { 'key' => 'signup_server-realtime', - 'section' => 'self-service', - 'description' => 'Run billing for signup server signups immediately, and do not provision accounts which subsequently have a balance.', + 'section' => 'signup', + 'description' => 'Run billing for self-signups immediately, and do not provision accounts which subsequently have a balance.', 'type' => 'checkbox', }, { 'key' => 'signup_server-classnum2', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Package Class for first optional purchase', 'type' => 'select-pkg_class', }, { 'key' => 'signup_server-classnum3', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Package Class for second optional purchase', 'type' => 'select-pkg_class', }, { 'key' => 'signup_server-third_party_as_card', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Allow customer payment type to be set to CARD even when using third-party credit card billing.', 'type' => 'checkbox', }, { 'key' => 'selfservice-xmlrpc', - 'section' => 'self-service', + 'section' => 'API', 'description' => 'Run a standalone self-service XML-RPC server on the backend (on port 8080).', 'type' => 'checkbox', }, @@ -2375,14 +2360,14 @@ and customer address. Include units.', { 'key' => 'cancel_msgnum', - 'section' => 'notification', + 'section' => 'cancellation', 'description' => 'Template to use for cancellation emails.', %msg_template_options, }, { 'key' => 'emailcancel', - 'section' => 'notification', + 'section' => 'cancellation', 'description' => 'Enable emailing of cancellation notices. Make sure to select the template in the cancel_msgnum option.', 'type' => 'checkbox', 'per_agent' => 1, @@ -2390,14 +2375,14 @@ and customer address. Include units.', { 'key' => 'bill_usage_on_cancel', - 'section' => 'billing', + 'section' => 'cancellation', 'description' => 'Enable automatic generation of an invoice for usage when a package is cancelled. Not all packages can do this. Usage data must already be available.', 'type' => 'checkbox', }, { 'key' => 'require_cardname', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Require an "Exact name on card" to be entered explicitly; don\'t default to using the first and last name.', 'type' => 'checkbox', }, @@ -2490,22 +2475,22 @@ and customer address. Include units.', { 'key' => 'welcome_msgnum', - 'section' => 'notification', - 'description' => 'Template to use for welcome messages when a svc_acct record is created.', + 'section' => 'deprecated', + 'description' => 'Deprecated; use a billing event instead. Used to be the template to use for welcome messages when a svc_acct record is created.', %msg_template_options, }, { 'key' => 'svc_acct_welcome_exclude', - 'section' => 'notification', - 'description' => 'A list of svc_acct services for which no welcome email is to be sent.', + 'section' => 'deprecated', + 'description' => 'Deprecated; use a billing event instead. A list of svc_acct services for which no welcome email is to be sent.', 'type' => 'select-part_svc', 'multiple' => 1, }, { 'key' => 'welcome_letter', - 'section' => '', + 'section' => 'notification', 'description' => 'Optional LaTex template file for a printed welcome letter. A welcome letter is printed the first time a cust_pkg record is created. See the <a href="http://search.cpan.org/dist/Text-Template/lib/Text/Template.pm">Text::Template</a> documentation and the billing documentation for details on the template substitution language. A variable exists for each fieldname in the customer record (<code>$first, $last, etc</code>). The following additional variables are available<ul><li><code>$payby</code> - a friendler represenation of the field<li><code>$payinfo</code> - the masked payment information<li><code>$expdate</code> - the time at which the payment method expires (a UNIX timestamp)<li><code>$returnaddress</code> - the invoice return address for this customer\'s agent</ul>', 'type' => 'textarea', }, @@ -2519,7 +2504,7 @@ and customer address. Include units.', { 'key' => 'payby', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'Available payment types.', 'type' => 'selectmultiple', 'select_enum' => [ qw(CARD DCRD CHEK DCHK) ], #BILL CASH WEST MCRD MCHK PPAL) ], @@ -2527,7 +2512,7 @@ and customer address. Include units.', { 'key' => 'banned_pay-pad', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Padding for encrypted storage of banned credit card hashes. If you already have new-style SHA512 entries in the banned_pay table, do not change as this will invalidate the old entries.', 'type' => 'text', }, @@ -2542,7 +2527,7 @@ and customer address. Include units.', { 'key' => 'require_cash_deposit_info', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'When recording cash payments, display bank deposit information fields.', 'type' => 'checkbox', }, @@ -2556,7 +2541,7 @@ and customer address. Include units.', { 'key' => 'radius-password', - 'section' => '', + 'section' => 'RADIUS', 'description' => 'RADIUS attribute for plain-text passwords.', 'type' => 'select', 'select_enum' => [ 'Password', 'User-Password', 'Cleartext-Password' ], @@ -2564,7 +2549,7 @@ and customer address. Include units.', { 'key' => 'radius-ip', - 'section' => '', + 'section' => 'RADIUS', 'description' => 'RADIUS attribute for IP addresses.', 'type' => 'select', 'select_enum' => [ 'Framed-IP-Address', 'Framed-Address' ], @@ -2573,56 +2558,56 @@ and customer address. Include units.', #http://dev.coova.org/svn/coova-chilli/doc/dictionary.chillispot { 'key' => 'radius-chillispot-max', - 'section' => '', + 'section' => 'RADIUS', 'description' => 'Enable ChilliSpot (and CoovaChilli) Max attributes, specifically ChilliSpot-Max-{Input,Output,Total}-{Octets,Gigawords}.', 'type' => 'checkbox', }, { 'key' => 'radius-canopy', - 'section' => '', + 'section' => 'RADIUS', 'description' => 'Enable RADIUS attributes for Cambium (formerly Motorola) Canopy (Motorola-Canopy-Gateway).', 'type' => 'checkbox', }, { 'key' => 'svc_broadband-radius', - 'section' => '', + 'section' => 'RADIUS', 'description' => 'Enable RADIUS groups for broadband services.', 'type' => 'checkbox', }, { 'key' => 'svc_acct-alldomains', - 'section' => '', + 'section' => 'services', 'description' => 'Allow accounts to select any domain in the database. Normally accounts can only select from the domain set in the service definition and those purchased by the customer.', 'type' => 'checkbox', }, { 'key' => 'dump-localdest', - 'section' => '', + 'section' => 'backup', 'description' => 'Destination for local database dumps (full path)', 'type' => 'text', }, { 'key' => 'dump-scpdest', - 'section' => '', + 'section' => 'backup', 'description' => 'Destination for scp database dumps: user@host:/path', 'type' => 'text', }, { 'key' => 'dump-pgpid', - 'section' => '', + 'section' => 'backup', 'description' => "Optional PGP public key user or key id for database dumps. The public key should exist on the freeside user's public keyring, and the gpg binary and GnuPG perl module should be installed.", 'type' => 'text', }, { 'key' => 'credit_card-recurring_billing_flag', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'This controls when the system passes the "recurring_billing" flag on credit card transactions. If supported by your processor (and the Business::OnlinePayment processor module), passing the flag indicates this is a recurring transaction and may turn off the CVV requirement. ', 'type' => 'select', 'select_hash' => [ @@ -2633,14 +2618,14 @@ and customer address. Include units.', { 'key' => 'credit_card-recurring_billing_acct_code', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'When the "recurring billing" flag is set, also set the "acct_code" to "rebill". Useful for reporting purposes with supported gateways (PlugNPay, others?)', 'type' => 'checkbox', }, { 'key' => 'cvv-save', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'NOT RECOMMENDED. Saves CVV2 information after the initial transaction for the selected credit card types. Enabling this option is almost certainly in violation of your merchant agreement(s), so please check them carefully before enabling this option for any credit card types.', 'type' => 'selectmultiple', 'select_enum' => \@card_types, @@ -2648,50 +2633,50 @@ and customer address. Include units.', { 'key' => 'signup-require_cvv', - 'section' => 'self-service', + 'section' => 'credit_cards', 'description' => 'Require CVV for credit card signup.', 'type' => 'checkbox', }, { 'key' => 'backoffice-require_cvv', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Require CVV for manual credit card entry.', 'type' => 'checkbox', }, { 'key' => 'selfservice-onfile_require_cvv', - 'section' => 'self-service', + 'section' => 'credit_cards', 'description' => 'Require CVV for on-file credit card during self-service payments.', 'type' => 'checkbox', }, { 'key' => 'selfservice-require_cvv', - 'section' => 'self-service', + 'section' => 'credit_cards', 'description' => 'Require CVV for credit card self-service payments, except for cards on-file.', 'type' => 'checkbox', }, { 'key' => 'manual_process-single_invoice_amount', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'When entering manual credit card and ACH payments, amount will not autofill if the customer has more than one open invoice', 'type' => 'checkbox', }, { 'key' => 'manual_process-pkgpart', - 'section' => 'billing', - 'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend. Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.', + 'section' => 'payments', + 'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend. WARNING: Although recently permitted to US merchants in general, specific consumer protection laws may prohibit or restrict this practice in California, Colorado, Connecticut, Florda, Kansas, Maine, Massachusetts, New York, Oklahome, and Texas. Surcharging is also generally prohibited in most countries outside the US, AU and UK.', 'type' => 'select-part_pkg', 'per_agent' => 1, }, { 'key' => 'manual_process-display', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'When using manual_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.', 'type' => 'select', 'select_hash' => [ @@ -2702,7 +2687,7 @@ and customer address. Include units.', { 'key' => 'manual_process-skip_first', - 'section' => 'billing', + 'section' => 'payments', 'description' => "When using manual_process-pkgpart, omit the fee if it is the customer's first payment.", 'type' => 'checkbox', }, @@ -2725,7 +2710,7 @@ and customer address. Include units.', { 'key' => 'selfservice_process-pkgpart', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'Package to add to each manual credit card and ACH payment entered by the customer themselves in the self-service interface. Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.', 'type' => 'select-part_pkg', 'per_agent' => 1, @@ -2733,7 +2718,7 @@ and customer address. Include units.', { 'key' => 'selfservice_process-display', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'When using selfservice_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.', 'type' => 'select', 'select_hash' => [ @@ -2744,7 +2729,7 @@ and customer address. Include units.', { 'key' => 'selfservice_process-skip_first', - 'section' => 'billing', + 'section' => 'payments', 'description' => "When using selfservice_process-pkgpart, omit the fee if it is the customer's first payment.", 'type' => 'checkbox', }, @@ -2776,13 +2761,13 @@ and customer address. Include units.', { 'key' => 'allow_negative_charges', - 'section' => 'billing', + 'section' => 'deprecated', 'description' => 'Allow negative charges. Normally not used unless importing data from a legacy system that requires this.', 'type' => 'checkbox', }, { 'key' => 'auto_unset_catchall', - 'section' => '', + 'section' => 'cancellation', 'description' => 'When canceling a svc_acct that is the email catchall for one or more svc_domains, automatically set their catchall fields to null. If this option is not set, the attempt will simply fail.', 'type' => 'checkbox', }, @@ -2796,14 +2781,14 @@ and customer address. Include units.', { 'key' => 'cust_pkg-change_svcpart', - 'section' => '', + 'section' => 'packages', 'description' => "When changing packages, move services even if svcparts don't match between old and new pacakge definitions.", 'type' => 'checkbox', }, { 'key' => 'cust_pkg-change_pkgpart-bill_now', - 'section' => '', + 'section' => 'RADIUS', 'description' => "When changing packages, bill the new package immediately. Useful for prepaid situations with RADIUS where an Expiration attribute based on the package must be present at all times.", 'type' => 'checkbox', }, @@ -2817,14 +2802,14 @@ and customer address. Include units.', { 'key' => 'svc_www-enable_subdomains', - 'section' => '', + 'section' => 'services', 'description' => 'Enable selection of specific subdomains for virtual host creation.', 'type' => 'checkbox', }, { 'key' => 'svc_www-usersvc_svcpart', - 'section' => '', + 'section' => 'services', 'description' => 'Allowable service definition svcparts for virtual hosts, one per line.', 'type' => 'select-part_svc', 'multiple' => 1, @@ -2974,14 +2959,14 @@ and customer address. Include units.', { 'key' => 'card_refund-days', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'After a payment, the number of days a refund link will be available for that payment. Defaults to 120.', 'type' => 'text', }, { 'key' => 'agent-showpasswords', - 'section' => '', + 'section' => 'deprecated', 'description' => 'Display unencrypted user passwords in the agent (reseller) interface', 'type' => 'checkbox', }, @@ -2996,7 +2981,7 @@ and customer address. Include units.', { 'key' => 'global_unique-phonenum', - 'section' => '', + 'section' => 'telephony', 'description' => 'Global phone number uniqueness control: none (usual setting - check countrycode+phonenumun uniqueness per exports), or countrycode+phonenum (all countrycode+phonenum pairs are globally unique, regardless of exports). disabled turns off duplicate checking completely and is STRONGLY NOT RECOMMENDED unless you REALLY need to turn this off.', 'type' => 'select', 'select_enum' => [ 'none', 'countrycode+phonenum', 'disabled' ], @@ -3004,7 +2989,7 @@ and customer address. Include units.', { 'key' => 'global_unique-pbx_title', - 'section' => '', + 'section' => 'telephony', 'description' => 'Global phone number uniqueness control: none (check uniqueness per exports), enabled (check across all services), or disabled (no duplicate checking).', 'type' => 'select', 'select_enum' => [ 'enabled', 'disabled' ], @@ -3012,7 +2997,7 @@ and customer address. Include units.', { 'key' => 'global_unique-pbx_id', - 'section' => '', + 'section' => 'telephony', 'description' => 'Global PBX id uniqueness control: none (check uniqueness per exports), enabled (check across all services), or disabled (no duplicate checking).', 'type' => 'select', 'select_enum' => [ 'enabled', 'disabled' ], @@ -3036,7 +3021,7 @@ and customer address. Include units.', { 'key' => 'ticket_system', 'section' => 'ticketing', - 'description' => 'Ticketing system integration. <b>RT_Internal</b> uses the built-in RT ticketing system (see the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:RT_Installation">integrated ticketing installation instructions</a>). <b>RT_External</b> accesses an external RT installation in a separate database (local or remote).', + 'description' => 'Ticketing system integration. <b>RT_Internal</b> uses the built-in RT ticketing system (see the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:RT_Installation">integrated ticketing installation instructions</a>). <b>RT_External</b> accesses an external RT installation in a separate database (local or remote).', 'type' => 'select', #'select_enum' => [ '', qw(RT_Internal RT_Libs RT_External) ], 'select_enum' => [ '', qw(RT_Internal RT_External) ], @@ -3045,7 +3030,7 @@ and customer address. Include units.', { 'key' => 'network_monitoring_system', 'section' => 'network_monitoring', - 'description' => 'Networking monitoring system (NMS) integration. <b>Torrus_Internal</b> uses the built-in Torrus ticketing system (see the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Torrus_Installation">integrated networking monitoring system installation instructions</a>).', + 'description' => 'Networking monitoring system (NMS) integration. <b>Torrus_Internal</b> uses the built-in Torrus network monitoring system (see the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:3:Documentation:Torrus_Installation">installation instructions</a>).', 'type' => 'select', 'select_enum' => [ '', qw(Torrus_Internal) ], }, @@ -3178,7 +3163,7 @@ and customer address. Include units.', { 'key' => 'ticket_system-appointment-queueid', - 'section' => 'ticketing', + 'section' => 'appointments', 'description' => 'Ticketing queue to use for appointments.', #false laziness w/above 'type' => 'select-sub', @@ -3206,7 +3191,7 @@ and customer address. Include units.', { 'key' => 'ticket_system-appointment-custom_field', - 'section' => 'ticketing', + 'section' => 'appointments', 'description' => 'Ticketing custom field to use as an appointment classification.', 'type' => 'text', }, @@ -3235,7 +3220,7 @@ and customer address. Include units.', { 'key' => 'company_name', - 'section' => 'required', + 'section' => 'important', 'description' => 'Your company name', 'type' => 'text', 'per_agent' => 1, #XXX just FS/FS/ClientAPI/Signup.pm @@ -3251,7 +3236,7 @@ and customer address. Include units.', { 'key' => 'company_address', - 'section' => 'required', + 'section' => 'important', 'description' => 'Your company address', 'type' => 'textarea', 'per_agent' => 1, @@ -3259,7 +3244,7 @@ and customer address. Include units.', { 'key' => 'company_phonenum', - 'section' => 'notification', + 'section' => 'important', 'description' => 'Your company phone number', 'type' => 'text', 'per_agent' => 1, @@ -3267,28 +3252,28 @@ and customer address. Include units.', { 'key' => 'address1-search', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Enable the ability to search the address1 field from the quick customer search. Not recommended in most cases as it tends to bring up too many search results - use explicit address searching from the advanced customer search instead.', 'type' => 'checkbox', }, { 'key' => 'address2-search', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Enable a "Unit" search box which searches the second address field. Useful for multi-tenant applications. See also: cust_main-require_address2', 'type' => 'checkbox', }, { 'key' => 'cust_main-require_address2', - 'section' => 'UI', - 'description' => 'Second address field is required (on service address only, if billing and service addresses differ). Also enables "Unit" labeling of address2 on customer view and edit pages. Useful for multi-tenant applications. See also: address2-search', + 'section' => 'addresses', + 'description' => 'Second address field is required. Also enables "Unit" labeling of address2 on customer view and edit pages. Useful for multi-tenant applications. See also: address2-search', # service address only part not working in the modern world, see #41184 (on service address only, if billing and service addresses differ) 'type' => 'checkbox', }, { 'key' => 'agent-ship_address', - 'section' => '', + 'section' => 'addresses', 'description' => "Use the agent's master service address as the service address (only ship_address2 can be entered, if blank on the master address). Useful for multi-tenant applications.", 'type' => 'checkbox', 'per_agent' => 1, @@ -3303,14 +3288,14 @@ and customer address. Include units.', { 'key' => 'hylafax', - 'section' => 'billing', + 'section' => 'deprecated', 'description' => 'Options for a HylaFAX server to enable the FAX invoice destination. They should be in the form of a space separated list of arguments to the Fax::Hylafax::Client::sendfax subroutine. You probably shouldn\'t override things like \'docfile\'. *Note* Only supported when using typeset invoices (see the invoice_latex configuration option).', 'type' => [qw( checkbox textarea )], }, { 'key' => 'cust_bill-ftpformat', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable FTP of raw invoice data - format.', 'type' => 'select', 'options' => [ spool_formats() ], @@ -3318,35 +3303,35 @@ and customer address. Include units.', { 'key' => 'cust_bill-ftpserver', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable FTP of raw invoice data - server.', 'type' => 'text', }, { 'key' => 'cust_bill-ftpusername', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable FTP of raw invoice data - server.', 'type' => 'text', }, { 'key' => 'cust_bill-ftppassword', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable FTP of raw invoice data - server.', 'type' => 'text', }, { 'key' => 'cust_bill-ftpdir', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable FTP of raw invoice data - server.', 'type' => 'text', }, { 'key' => 'cust_bill-spoolformat', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable spooling of raw invoice data - format.', 'type' => 'select', 'options' => [ spool_formats() ], @@ -3354,14 +3339,14 @@ and customer address. Include units.', { 'key' => 'cust_bill-spoolagent', - 'section' => 'invoicing', + 'section' => 'print_services', 'description' => 'Enable per-agent spooling of raw invoice data.', 'type' => 'checkbox', }, { 'key' => 'bridgestone-batch_counter', - 'section' => '', + 'section' => 'print_services', 'description' => 'Batch counter for spool files. Increments every time a spool file is uploaded.', 'type' => 'text', 'per_agent' => 1, @@ -3369,7 +3354,7 @@ and customer address. Include units.', { 'key' => 'bridgestone-prefix', - 'section' => '', + 'section' => 'print_services', 'description' => 'Agent identifier for uploading to BABT printing service.', 'type' => 'text', 'per_agent' => 1, @@ -3377,7 +3362,7 @@ and customer address. Include units.', { 'key' => 'bridgestone-confirm_template', - 'section' => '', + 'section' => 'print_services', 'description' => 'Confirmation email template for uploading to BABT service. Text::Template format, with variables "$zipfile" (name of the zipped file), "$seq" (sequence number), "$prefix" (user ID string), and "$rows" (number of records in the file). Should include Subject: and To: headers, separated from the rest of the message by a blank line.', # this could use a true message template, but it's hard to see how that # would make the world a better place @@ -3387,7 +3372,7 @@ and customer address. Include units.', { 'key' => 'ics-confirm_template', - 'section' => '', + 'section' => 'print_services', 'description' => 'Confirmation email template for uploading to ICS invoice printing. Text::Template format, with variables "%count" and "%sum".', 'type' => 'textarea', 'per_agent' => 1, @@ -3395,28 +3380,28 @@ and customer address. Include units.', { 'key' => 'svc_acct-usage_suspend', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'Suspends the package an account belongs to when svc_acct.seconds or a bytecount is decremented to 0 or below (accounts with an empty seconds and up|down|totalbytes value are ignored). Typically used in conjunction with prepaid packages and freeside-sqlradius-radacctd.', 'type' => 'checkbox', }, { 'key' => 'svc_acct-usage_unsuspend', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'Unuspends the package an account belongs to when svc_acct.seconds or a bytecount is incremented from 0 or below to a positive value (accounts with an empty seconds and up|down|totalbytes value are ignored). Typically used in conjunction with prepaid packages and freeside-sqlradius-radacctd.', 'type' => 'checkbox', }, { 'key' => 'svc_acct-usage_threshold', - 'section' => 'billing', + 'section' => 'notification', 'description' => 'The threshold (expressed as percentage) of acct.seconds or acct.up|down|totalbytes at which a warning message is sent to a service holder. Typically used in conjunction with prepaid packages and freeside-sqlradius-radacctd.', 'type' => 'text', }, { 'key' => 'overlimit_groups', - 'section' => '', + 'section' => 'suspension', 'description' => 'RADIUS group(s) to assign to svc_acct which has exceeded its bandwidth or time limit.', 'type' => 'select-sub', 'per_agent' => 1, @@ -3437,7 +3422,7 @@ and customer address. Include units.', { 'key' => 'cust-fields', - 'section' => 'UI', + 'section' => 'reporting', 'description' => 'Which customer fields to display on reports by default', 'type' => 'select', 'select_hash' => [ FS::ConfDefaults->cust_fields_avail() ], @@ -3445,7 +3430,7 @@ and customer address. Include units.', { 'key' => 'cust_location-label_prefix', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Optional "site ID" to show in the location label', 'type' => 'select', 'select_hash' => [ '' => '', @@ -3456,42 +3441,35 @@ and customer address. Include units.', { 'key' => 'cust_pkg-display_times', - 'section' => 'UI', + 'section' => 'packages', 'description' => 'Display full timestamps (not just dates) for customer packages. Useful if you are doing real-time things like hourly prepaid.', 'type' => 'checkbox', }, { - 'key' => 'cust_pkg-always_show_location', - 'section' => 'UI', - 'description' => "Always display package locations, even when they're all the default service address.", - 'type' => 'checkbox', - }, - - { 'key' => 'cust_pkg-group_by_location', - 'section' => 'UI', + 'section' => 'packages', 'description' => "Group packages by location.", 'type' => 'checkbox', }, { 'key' => 'cust_pkg-large_pkg_size', - 'section' => 'UI', + 'section' => 'scalability', 'description' => "In customer view, summarize packages with more than this many services. Set to zero to never summarize packages.", 'type' => 'text', }, { 'key' => 'cust_pkg-hide_discontinued-part_svc', - 'section' => 'UI', + 'section' => 'packages', 'description' => "In customer view, hide provisioned services which are no longer available in the package definition. Not normally used except for very specific situations as it hides still-provisioned services.", 'type' => 'checkbox', }, { 'key' => 'part_pkg-show_fcc_options', - 'section' => 'UI', + 'section' => 'packages', 'description' => "Show fields on package definitions for FCC Form 477 classification", 'type' => 'checkbox', }, @@ -3526,7 +3504,7 @@ and customer address. Include units.', { 'key' => 'echeck-country', - 'section' => 'billing', + 'section' => 'e-checks', 'description' => 'Format electronic check information for the specified country.', 'type' => 'select', 'select_hash' => [ 'US' => 'United States', @@ -3564,13 +3542,6 @@ and customer address. Include units.', }, { - 'key' => 'voip-cust_email_csv_cdr', - 'section' => 'deprecated', - 'description' => 'Deprecated, see voip-cdr_email_attach instead. Used to enable the per-customer option for including CDR information as a CSV attachment on emailed invoices.', - 'type' => 'checkbox', - }, - - { 'key' => 'voip-cdr_email_attach', 'section' => 'telephony', 'description' => 'Enable the per-customer option for including CDR information as an attachment on emailed invoices.', @@ -3583,21 +3554,21 @@ and customer address. Include units.', { 'key' => 'cgp_rule-domain_templates', - 'section' => '', + 'section' => 'services', 'description' => 'Communigate Pro rule templates for domains, one per line, "svcnum Name"', 'type' => 'textarea', }, { 'key' => 'svc_forward-no_srcsvc', - 'section' => '', + 'section' => 'services', 'description' => "Don't allow forwards from existing accounts, only arbitrary addresses. Useful when exporting to systems such as Communigate Pro which treat forwards in this fashion.", 'type' => 'checkbox', }, { 'key' => 'svc_forward-arbitrary_dst', - 'section' => '', + 'section' => 'services', 'description' => "Allow forwards to point to arbitrary strings that don't necessarily look like email addresses. Only used when using forwards for weird, non-email things.", 'type' => 'checkbox', }, @@ -3624,6 +3595,13 @@ and customer address. Include units.', }, { + 'key' => 'invoice-all_pkg_addresses', + 'section' => 'invoicing', + 'description' => 'Show all package addresses on invoices, even the default.', + 'type' => 'checkbox', + }, + + { 'key' => 'invoice-unitprice', 'section' => 'invoicing', 'description' => 'Enable unit pricing on invoices and quantities on packages.', @@ -3646,7 +3624,7 @@ and customer address. Include units.', { 'key' => 'postal_invoice-fee_pkgpart', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'This allows selection of a package to insert on invoices for customers with postal invoices selected.', 'type' => 'select-part_pkg', 'per_agent' => 1, @@ -3654,7 +3632,7 @@ and customer address. Include units.', { 'key' => 'postal_invoice-recurring_only', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'The postal invoice fee is omitted on invoices without recurring charges when this is set.', 'type' => 'checkbox', }, @@ -3669,7 +3647,7 @@ and customer address. Include units.', { 'key' => 'batch-enable_payby', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Enable batch processing for the specified payment types.', 'type' => 'selectmultiple', 'select_enum' => [qw( CARD CHEK )], @@ -3677,7 +3655,7 @@ and customer address. Include units.', { 'key' => 'realtime-disable_payby', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'Disable realtime processing for the specified payment types.', 'type' => 'selectmultiple', 'select_enum' => [qw( CARD CHEK )], @@ -3685,7 +3663,7 @@ and customer address. Include units.', { 'key' => 'batch-default_format', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Default format for batches.', 'type' => 'select', 'select_enum' => [ 'NACHA', 'csv-td_canada_trust-merchant_pc_batch', @@ -3695,34 +3673,34 @@ and customer address. Include units.', }, { 'key' => 'batch-gateway-CARD', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Business::BatchPayment gateway for credit card batches.', %batch_gateway_options, }, { 'key' => 'batch-gateway-CHEK', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Business::BatchPayment gateway for check batches.', %batch_gateway_options, }, { 'key' => 'batch-reconsider', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Allow imported batch results to change the status of payments from previous imports. Enable this only if your gateway is known to send both positive and negative results for the same batch.', 'type' => 'checkbox', }, { 'key' => 'batch-auto_resolve_days', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Automatically resolve payment batches this many days after they were first downloaded.', 'type' => 'text', }, { 'key' => 'batch-auto_resolve_status', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'When automatically resolving payment batches, take this action for payments of unknown status.', 'type' => 'select', 'select_enum' => [ 'approve', 'decline' ], @@ -3731,7 +3709,7 @@ and customer address. Include units.', # replaces batch-errors_to (sent email on error) { 'key' => 'batch-errors_not_fatal', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'If checked, when importing batches from a gateway, item errors will be recorded in the system log without aborting processing. If unchecked, batch processing will fail on error.', 'type' => 'checkbox', }, @@ -3739,7 +3717,7 @@ and customer address. Include units.', #lists could be auto-generated from pay_batch info { 'key' => 'batch-fixed_format-CARD', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Fixed (unchangeable) format for credit card batches.', 'type' => 'select', 'select_enum' => [ 'csv-td_canada_trust-merchant_pc_batch', 'BoM', 'PAP' , @@ -3748,7 +3726,7 @@ and customer address. Include units.', { 'key' => 'batch-fixed_format-CHEK', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Fixed (unchangeable) format for electronic check batches.', 'type' => 'select', 'select_enum' => [ 'NACHA', 'csv-td_canada_trust-merchant_pc_batch', 'BoM', @@ -3759,70 +3737,70 @@ and customer address. Include units.', { 'key' => 'batch-increment_expiration', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Increment expiration date years in batches until cards are current. Make sure this is acceptable to your batching provider before enabling.', 'type' => 'checkbox' }, { 'key' => 'batchconfig-BoM', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for Bank of Montreal batching, seven lines: 1. Origin ID, 2. Datacenter, 3. Typecode, 4. Short name, 5. Long name, 6. Bank, 7. Bank account', 'type' => 'textarea', }, { 'key' => 'batchconfig-CIBC', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for Canadian Imperial Bank of Commerce, six lines: 1. Origin ID, 2. Datacenter, 3. Typecode, 4. Short name, 5. Bank, 6. Bank account', 'type' => 'textarea', }, { 'key' => 'batchconfig-PAP', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for PAP batching, seven lines: 1. Origin ID, 2. Datacenter, 3. Typecode, 4. Short name, 5. Long name, 6. Bank, 7. Bank account', 'type' => 'textarea', }, { 'key' => 'batchconfig-csv-chase_canada-E-xactBatch', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Gateway ID for Chase Canada E-xact batching', 'type' => 'text', }, { 'key' => 'batchconfig-paymentech', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for Chase Paymentech batching, six lines: 1. BIN, 2. Terminal ID, 3. Merchant ID, 4. Username, 5. Password (for batch uploads), 6. Flag to send recurring indicator.', 'type' => 'textarea', }, { 'key' => 'batchconfig-RBC', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for Royal Bank of Canada PDS batching, five lines: 1. Client number, 2. Short name, 3. Long name, 4. Transaction code 5. (optional) set to TEST to turn on test mode.', 'type' => 'textarea', }, { 'key' => 'batchconfig-RBC-login', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'FTPS login for uploading Royal Bank of Canada batches. Two lines: 1. username, 2. password. If not supplied, batches can still be created but not automatically uploaded.', 'type' => 'textarea', }, { 'key' => 'batchconfig-td_eft1464', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for TD Bank EFT1464 batching, seven lines: 1. Originator ID, 2. Datacenter Code, 3. Short name, 4. Long name, 5. Returned payment branch number, 6. Returned payment account, 7. Transaction code.', 'type' => 'textarea', }, { 'key' => 'batchconfig-eft_canada', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for EFT Canada batching, five lines: 1. SFTP username, 2. SFTP password, 3. Business transaction code, 4. Personal transaction code, 5. Number of days to delay process date. If you are using separate per-agent batches (batch-spoolagent), you must set this option separately for each agent, as the global setting will be ignored.', 'type' => 'textarea', 'per_agent' => 1, @@ -3830,42 +3808,42 @@ and customer address. Include units.', { 'key' => 'batchconfig-nacha-destination', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for NACHA batching, Destination (9 digit transit routing number).', 'type' => 'text', }, { 'key' => 'batchconfig-nacha-destination_name', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for NACHA batching, Destination (Bank Name, up to 23 characters).', 'type' => 'text', }, { 'key' => 'batchconfig-nacha-origin', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for NACHA batching, Origin (your 10-digit company number, IRS tax ID recommended).', 'type' => 'text', }, { 'key' => 'batchconfig-nacha-origin_name', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Configuration for NACHA batching, Origin name (defaults to company name, but sometimes bank name is needed instead.)', 'type' => 'text', }, { 'key' => 'batch-manual_approval', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Allow manual batch closure, which will approve all payments that do not yet have a status. This is not advised unless needed for specific payment processors that provide a report of rejected rather than approved payments.', 'type' => 'checkbox', }, { 'key' => 'batch-spoolagent', - 'section' => 'billing', + 'section' => 'payment_batching', 'description' => 'Store payment batches per-agent.', 'type' => 'checkbox', }, @@ -3886,35 +3864,35 @@ and customer address. Include units.', { 'key' => 'cust_main-packages-years', - 'section' => 'UI', + 'section' => 'packages', 'description' => 'Number of years to show old (cancelled and one-time charge) packages by default. Currently defaults to 2.', 'type' => 'text', }, { 'key' => 'cust_main-use_comments', - 'section' => 'UI', + 'section' => 'deprecated', 'description' => 'Display free form comments on the customer edit screen. Useful as a scratch pad.', 'type' => 'checkbox', }, { 'key' => 'cust_main-disable_notes', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Disable new style customer notes - timestamped and user identified customer notes. Useful in tracking who did what.', 'type' => 'checkbox', }, { 'key' => 'cust_main_note-display_times', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Display full timestamps (not just dates) for customer notes.', 'type' => 'checkbox', }, { 'key' => 'cust_main-ticket_statuses', - 'section' => 'UI', + 'section' => 'ticketing', 'description' => 'Show tickets with these statuses on the customer view page.', 'type' => 'selectmultiple', 'select_enum' => [qw( new open stalled resolved rejected deleted )], @@ -3922,49 +3900,56 @@ and customer address. Include units.', { 'key' => 'cust_main-max_tickets', - 'section' => 'UI', + 'section' => 'ticketing', 'description' => 'Maximum number of tickets to show on the customer view page.', 'type' => 'text', }, { 'key' => 'cust_main-enable_birthdate', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Enable tracking of a birth date with each customer record', 'type' => 'checkbox', }, { 'key' => 'cust_main-enable_spouse', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Enable tracking of a spouse\'s name and date of birth with each customer record', 'type' => 'checkbox', }, { 'key' => 'cust_main-enable_anniversary_date', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Enable tracking of an anniversary date with each customer record', 'type' => 'checkbox', }, { 'key' => 'cust_main-edit_calling_list_exempt', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Display the "calling_list_exempt" checkbox on customer edit.', 'type' => 'checkbox', }, { 'key' => 'support-key', - 'section' => '', - 'description' => 'A support key enables access to commercial services delivered over the network, such as the payroll module, access to the internal ticket system, priority support and optional backups.', + 'section' => 'important', + 'description' => 'A support key enables access to <A HREF="http://freeside.biz/freeside/services.html#support">commercial services</A> delivered over the network, such as address normalization and invoice printing.', + 'type' => 'text', + }, + + { + 'key' => 'freesideinc-webservice-svcpart', + 'section' => 'development', + 'description' => 'Do not set this.', 'type' => 'text', }, { 'key' => 'card-types', - 'section' => 'billing', + 'section' => 'credit_cards', 'description' => 'Select one or more card types to enable only those card types. If no card types are selected, all card types are available.', 'type' => 'selectmultiple', 'select_enum' => \@card_types, @@ -3972,26 +3957,26 @@ and customer address. Include units.', { 'key' => 'disable-fuzzy', - 'section' => 'UI', + 'section' => 'scalability', 'description' => 'Disable fuzzy searching. Speeds up searching for large sites, but only shows exact matches.', 'type' => 'checkbox', }, { 'key' => 'fuzzy-fuzziness', - 'section' => 'UI', + 'section' => 'scalability', 'description' => 'Set the "fuzziness" of fuzzy searching (see the String::Approx manpage for details). Defaults to 10%', 'type' => 'text', }, { 'key' => 'pkg_referral', - 'section' => '', + 'section' => 'packages', 'description' => 'Enable package-specific advertising sources.', 'type' => 'checkbox', }, { 'key' => 'pkg_referral-multiple', - 'section' => '', + 'section' => 'packages', 'description' => 'In addition, allow multiple advertising sources to be associated with a single package.', 'type' => 'checkbox', }, @@ -4020,7 +4005,7 @@ and customer address. Include units.', { 'key' => 'logo.png', - 'section' => 'UI', #'invoicing' ? + 'section' => 'important', #'invoicing' ? 'description' => 'Company logo for HTML invoices and the backoffice interface, in PNG format. Suggested size somewhere near 92x62.', 'type' => 'image', 'per_agent' => 1, #XXX just view/logo.cgi, which is for the global @@ -4030,8 +4015,8 @@ and customer address. Include units.', { 'key' => 'logo.eps', - 'section' => 'invoicing', - 'description' => 'Company logo for printed and PDF invoices, in EPS format.', + 'section' => 'printing', + 'description' => 'Company logo for printed and PDF invoices and quotations, in EPS format.', 'type' => 'image', 'per_agent' => 1, #XXX as above, kinda 'per_locale' => 1, @@ -4052,14 +4037,16 @@ and customer address. Include units.', 'select_enum' => [ '1 hour', '2 hours', '4 hours', '8 hours', '1 day', '1 week', ], }, - { - 'key' => 'password-generated-characters', - 'section' => 'password', - 'description' => 'Set of characters to use when generating random passwords. This must contain at least one lowercase letter, uppercase letter, digit, and punctuation mark.', - 'type' => 'textarea', - }, + # 3.x-only options for a more tolerant password policy # { +# 'key' => 'password-generated-characters', +# 'section' => 'password', +# 'description' => 'Set of characters to use when generating random passwords. This must contain at least one lowercase letter, uppercase letter, digit, and punctuation mark.', +# 'type' => 'textarea', +# }, +# +# { # 'key' => 'password-no_reuse', # 'section' => 'password', # 'description' => 'Minimum number of password changes before a password can be reused. By default, passwords can be reused without restriction.', @@ -4082,21 +4069,21 @@ and customer address. Include units.', { 'key' => 'disable_void_after', - 'section' => 'billing', + 'section' => 'payments', 'description' => 'Number of seconds after which freeside won\'t attempt to VOID a payment first when performing a refund.', 'type' => 'text', }, { 'key' => 'disable_line_item_date_ranges', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Prevent freeside from automatically generating date ranges on invoice line items.', 'type' => 'checkbox', }, { 'key' => 'cust_bill-line_item-date_style', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Display format for line item date ranges on invoice line items.', 'type' => 'select', 'select_hash' => [ '' => 'STARTDATE-ENDDATE', @@ -4108,7 +4095,7 @@ and customer address. Include units.', { 'key' => 'cust_bill-line_item-date_style-non_monthly', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'If set, override cust_bill-line_item-date_style for non-monthly charges.', 'type' => 'select', 'select_hash' => [ '' => 'Default', @@ -4121,7 +4108,7 @@ and customer address. Include units.', { 'key' => 'cust_bill-line_item-date_description', - 'section' => 'billing', + 'section' => 'invoicing', 'description' => 'Text to display for "DATE_DESC" when using cust_bill-line_item-date_style DATE_DESC MONTHNAME.', 'type' => 'text', 'per_agent' => 1, @@ -4129,7 +4116,7 @@ and customer address. Include units.', { 'key' => 'support_packages', - 'section' => '', + 'section' => 'development', 'description' => 'A list of packages eligible for RT ticket time transfer, one pkgpart per line.', #this should really be a select multiple, or specified in the packages themselves... 'type' => 'select-part_pkg', 'multiple' => 1, @@ -4137,7 +4124,7 @@ and customer address. Include units.', { 'key' => 'cust_main-require_phone', - 'section' => '', + 'section' => 'customer_fields', 'description' => 'Require daytime or night phone for all customer records.', 'type' => 'checkbox', 'per_agent' => 1, @@ -4145,7 +4132,7 @@ and customer address. Include units.', { 'key' => 'cust_main-require_invoicing_list_email', - 'section' => '', + 'section' => 'customer_fields', 'description' => 'Email address field is required: require at least one invoicing email address for all customer records.', 'type' => 'checkbox', 'per_agent' => 1, @@ -4153,7 +4140,7 @@ and customer address. Include units.', { 'key' => 'cust_main-check_unique', - 'section' => '', + 'section' => 'customer_fields', 'description' => 'Warn before creating a customer record where these fields duplicate another customer.', 'type' => 'select', 'multiple' => 1, @@ -4164,41 +4151,26 @@ and customer address. Include units.', { 'key' => 'svc_acct-display_paid_time_remaining', - 'section' => '', + 'section' => 'services', 'description' => 'Show paid time remaining in addition to time remaining.', 'type' => 'checkbox', }, { 'key' => 'cancel_credit_type', - 'section' => 'billing', + 'section' => 'cancellation', 'description' => 'The group to use for new, automatically generated credit reasons resulting from cancellation.', reason_type_options('R'), }, { 'key' => 'suspend_credit_type', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'The group to use for new, automatically generated credit reasons resulting from package suspension.', reason_type_options('R'), }, { - 'key' => 'referral_credit_type', - 'section' => 'deprecated', - 'description' => 'Used to be the group to use for new, automatically generated credit reasons resulting from referrals. Now set in a package billing event for the referral.', - reason_type_options('R'), - }, - - # was only used to negate invoices during signup when card was declined, now we just void - { - 'key' => 'signup_credit_type', - 'section' => 'deprecated', #self-service? - 'description' => 'The group to use for new, automatically generated credit reasons resulting from signup and self-service declines.', - reason_type_options('R'), - }, - - { 'key' => 'prepayment_discounts-credit_type', 'section' => 'billing', 'description' => 'Enables the offering of prepayment discounts and establishes the credit reason type.', @@ -4207,7 +4179,7 @@ and customer address. Include units.', { 'key' => 'cust_main-agent_custid-format', - 'section' => '', + 'section' => 'customer_number', 'description' => 'Enables searching of various formatted values in cust_main.agent_custid', 'type' => 'select', 'select_hash' => [ @@ -4219,7 +4191,7 @@ and customer address. Include units.', { 'key' => 'card_masking_method', - 'section' => 'UI', + 'section' => 'credit_cards', 'description' => 'Digits to display when masking credit cards. Note that the first six digits are necessary to canonically identify the credit card type (Visa/MC, Amex, Discover, Maestro, etc.) in all cases. The first four digits can identify the most common credit card types in most cases (Visa/MC, Amex, and Discover). The first two digits can distinguish between Visa/MC and Amex. Note: You should manually remove stored paymasks if you change this value on an existing database, to avoid problems using stored cards.', 'type' => 'select', 'select_hash' => [ @@ -4236,7 +4208,7 @@ and customer address. Include units.', { 'key' => 'disable_previous_balance', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show new charges only; do not list previous invoices, payments, or credits on the invoice.', 'type' => 'checkbox', 'per_agent' => 1, @@ -4244,63 +4216,63 @@ and customer address. Include units.', { 'key' => 'previous_balance-exclude_from_total', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show separate totals for previous invoice balance and new charges. Only meaningful when invoice_sections is false.', 'type' => 'checkbox', }, { 'key' => 'previous_balance-text', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Text for the label of the total previous balance, when it is shown separately. Defaults to "Previous Balance".', 'type' => 'text', }, { 'key' => 'previous_balance-text-total_new_charges', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Text for the label of the total of new charges, when it is shown separately. If invoice_show_prior_due_date is enabled, the due date of current charges will be appended. Defaults to "Total New Charges".', 'type' => 'text', }, { 'key' => 'previous_balance-section', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show previous invoice balances in a separate invoice section. Does not require invoice_sections to be enabled.', 'type' => 'checkbox', }, { 'key' => 'previous_balance-summary_only', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Only show a single line summarizing the total previous balance rather than one line per invoice.', 'type' => 'checkbox', }, { 'key' => 'previous_balance-show_credit', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show the customer\'s credit balance on invoices when applicable.', 'type' => 'checkbox', }, { 'key' => 'previous_balance-show_on_statements', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show previous invoices on statements, without itemized charges.', 'type' => 'checkbox', }, { 'key' => 'previous_balance-payments_since', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Instead of showing payments (and credits) applied to the invoice, show those received since the previous invoice date.', 'type' => 'checkbox', }, { 'key' => 'previous_invoice_history', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Show a month-by-month history of the customer\'s '. 'billing amounts. This requires template '. 'modification and is currently not supported on the '. @@ -4310,90 +4282,83 @@ and customer address. Include units.', { 'key' => 'balance_due_below_line', - 'section' => 'invoicing', + 'section' => 'invoice_balances', 'description' => 'Place the balance due message below a line. Only meaningful when when invoice_sections is false.', 'type' => 'checkbox', }, { 'key' => 'always_show_tax', - 'section' => 'invoicing', + 'section' => 'taxation', 'description' => 'Show a line for tax on the invoice even when the tax is zero. Optionally provide text for the tax name to show.', 'type' => [ qw(checkbox text) ], }, { 'key' => 'address_standardize_method', - 'section' => 'UI', #??? + 'section' => 'addresses', #??? 'description' => 'Method for standardizing customer addresses.', 'type' => 'select', 'select_hash' => [ '' => '', 'uscensus' => 'U.S. Census Bureau', 'usps' => 'U.S. Postal Service', - 'tomtom' => 'TomTom', 'melissa' => 'Melissa WebSmart', + 'freeside' => 'Freeside web service (support contract required)', ], }, { 'key' => 'usps_webtools-userid', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Production UserID for USPS web tools. Enables USPS address standardization. See the <a href="http://www.usps.com/webtools/">USPS website</a>, register and agree not to use the tools for batch purposes.', 'type' => 'text', }, { 'key' => 'usps_webtools-password', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Production password for USPS web tools. Enables USPS address standardization. See <a href="http://www.usps.com/webtools/">USPS website</a>, register and agree not to use the tools for batch purposes.', 'type' => 'text', }, { - 'key' => 'tomtom-userid', - 'section' => 'UI', - 'description' => 'TomTom geocoding service API key. See <a href="http://geocoder.tomtom.com/">the TomTom website</a> to obtain a key. This is recommended for addresses in the United States only.', - 'type' => 'text', - }, - - { 'key' => 'melissa-userid', - 'section' => 'UI', # it's really not... + 'section' => 'addresses', # it's really not... 'description' => 'User ID for Melissa WebSmart service. See <a href="http://www.melissadata.com/">the Melissa website</a> for access and pricing.', 'type' => 'text', }, { 'key' => 'melissa-enable_geocoding', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Use the Melissa service for census tract and coordinate lookups. Enable this only if your subscription includes geocoding access.', 'type' => 'checkbox', }, { 'key' => 'cust_main-auto_standardize_address', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'When using USPS web tools, automatically standardize the address without asking.', 'type' => 'checkbox', }, { 'key' => 'cust_main-require_censustract', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Customer is required to have a census tract. Useful for FCC form 477 reports. See also: cust_main-auto_standardize_address', 'type' => 'checkbox', }, { 'key' => 'cust_main-no_city_in_address', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Turn off City for billing & shipping addresses', 'type' => 'checkbox', }, { 'key' => 'census_year', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'The year to use in census tract lookups. NOTE: you need to select 2012 or 2013 for Year 2010 Census tract codes. A selection of 2011 provides Year 2000 Census tract codes. Use the freeside-censustract-update tool if exisitng customers need to be changed.', 'type' => 'select', 'select_enum' => [ qw( 2013 2012 2011 ) ], @@ -4414,78 +4379,79 @@ and customer address. Include units.', { 'key' => 'company_latitude', - 'section' => 'UI', - 'description' => 'Your company latitude (-90 through 90)', + 'section' => 'taxation', + 'description' => 'For Avalara taxation, your company latitude (-90 through 90)', 'type' => 'text', }, { 'key' => 'company_longitude', - 'section' => 'UI', - 'description' => 'Your company longitude (-180 thru 180)', + 'section' => 'taxation', + 'description' => 'For Avalara taxation, your company longitude (-180 thru 180)', 'type' => 'text', }, - { - 'key' => 'geocode_module', - 'section' => '', - 'description' => 'Module to geocode (retrieve a latitude and longitude for) addresses', - 'type' => 'select', - 'select_enum' => [ 'Geo::Coder::Googlev3' ], - }, + #if we can't change it from the default yet, what good is it to the end-user? + #{ + # 'key' => 'geocode_module', + # 'section' => 'addresses', + # 'description' => 'Module to geocode (retrieve a latitude and longitude for) addresses', + # 'type' => 'select', + # 'select_enum' => [ 'Geo::Coder::Googlev3' ], + #}, { 'key' => 'geocode-require_nw_coordinates', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Require latitude and longitude in the North Western quadrant, e.g. for North American co-ordinates, etc.', 'type' => 'checkbox', }, { 'key' => 'disable_acl_changes', - 'section' => '', + 'section' => 'development', 'description' => 'Disable all ACL changes, for demos.', 'type' => 'checkbox', }, { 'key' => 'disable_settings_changes', - 'section' => '', + 'section' => 'development', 'description' => 'Disable all settings changes, for demos, except for the usernames given in the comma-separated list.', 'type' => [qw( checkbox text )], }, { 'key' => 'cust_main-edit_agent_custid', - 'section' => 'UI', + 'section' => 'customer_number', 'description' => 'Enable editing of the agent_custid field.', 'type' => 'checkbox', }, { 'key' => 'cust_main-default_agent_custid', - 'section' => 'UI', + 'section' => 'customer_number', 'description' => 'Display the agent_custid field when available instead of the custnum field. Restart Apache after changing.', 'type' => 'checkbox', }, { 'key' => 'cust_main-title-display_custnum', - 'section' => 'UI', - 'description' => 'Add the display_custom (agent_custid or custnum) to the title on customer view pages.', + 'section' => 'customer_number', + 'description' => 'Add the display_custnum (agent_custid or custnum) to the title on customer view pages.', 'type' => 'checkbox', }, { 'key' => 'cust_bill-default_agent_invid', - 'section' => 'UI', + 'section' => 'invoicing', 'description' => 'Display the agent_invid field when available instead of the invnum field.', 'type' => 'checkbox', }, { 'key' => 'cust_main-auto_agent_custid', - 'section' => 'UI', + 'section' => 'customer_number', 'description' => 'Automatically assign an agent_custid - select format', 'type' => 'select', 'select_hash' => [ '' => 'No', @@ -4495,7 +4461,7 @@ and customer address. Include units.', { 'key' => 'cust_main-custnum-display_prefix', - 'section' => 'UI', + 'section' => 'customer_number', 'description' => 'Prefix the customer number with this string for display purposes.', 'type' => 'text', 'per_agent' => 1, @@ -4503,45 +4469,45 @@ and customer address. Include units.', { 'key' => 'cust_main-custnum-display_length', - 'section' => 'UI', + 'section' => 'customer_number', 'description' => 'Zero fill the customer number to this many digits for display purposes. Restart Apache after changing.', 'type' => 'text', }, { 'key' => 'cust_main-default_areacode', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Default area code for customers.', 'type' => 'text', }, { 'key' => 'order_pkg-no_start_date', - 'section' => 'UI', + 'section' => 'packages', 'description' => 'Don\'t set a default start date for new packages.', 'type' => 'checkbox', }, { 'key' => 'part_pkg-delay_start', - 'section' => '', + 'section' => 'packages', 'description' => 'Enabled "delayed start" option for packages.', 'type' => 'checkbox', }, { 'key' => 'part_pkg-delay_cancel-days', - 'section' => '', - 'description' => 'Expire packages in this many days when using delay_cancel (default is 1)', + 'section' => 'cancellation', + 'description' => 'Number of days to suspend when using automatic suspension period before cancel (default is 1)', 'type' => 'text', 'validate' => sub { (($_[0] =~ /^\d*$/) && (($_[0] eq '') || $_[0])) - ? 'Must specify an integer number of days' - : '' } + ? '' + : 'Must specify an integer number of days' } }, { 'key' => 'mcp_svcpart', - 'section' => '', + 'section' => 'development', 'description' => 'Master Control Program svcpart. Leave this blank.', 'type' => 'text', #select-part_svc }, @@ -4562,21 +4528,21 @@ and customer address. Include units.', { 'key' => 'suspend_email_admin', - 'section' => '', + 'section' => 'suspension', 'description' => 'Destination admin email address to enable suspension notices', 'type' => 'text', }, { 'key' => 'unsuspend_email_admin', - 'section' => '', + 'section' => 'suspension', 'description' => 'Destination admin email address to enable unsuspension notices', 'type' => 'text', }, { 'key' => 'selfservice-head', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML for the HEAD section of the self-service interface, typically used for LINK stylesheet tags', 'type' => 'textarea', #htmlarea? 'per_agent' => 1, @@ -4585,7 +4551,7 @@ and customer address. Include units.', { 'key' => 'selfservice-body_header', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML header for the self-service interface', 'type' => 'textarea', #htmlarea? 'per_agent' => 1, @@ -4593,7 +4559,7 @@ and customer address. Include units.', { 'key' => 'selfservice-body_footer', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML footer for the self-service interface', 'type' => 'textarea', #htmlarea? 'per_agent' => 1, @@ -4602,7 +4568,7 @@ and customer address. Include units.', { 'key' => 'selfservice-body_bgcolor', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML background color for the self-service interface, for example, #FFFFFF', 'type' => 'text', 'per_agent' => 1, @@ -4610,7 +4576,7 @@ and customer address. Include units.', { 'key' => 'selfservice-box_bgcolor', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML color for self-service interface input boxes, for example, #C0C0C0', 'type' => 'text', 'per_agent' => 1, @@ -4618,7 +4584,7 @@ and customer address. Include units.', { 'key' => 'selfservice-stripe1_bgcolor', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML color for self-service interface lists (primary stripe), for example, #FFFFFF', 'type' => 'text', 'per_agent' => 1, @@ -4626,7 +4592,7 @@ and customer address. Include units.', { 'key' => 'selfservice-stripe2_bgcolor', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML color for self-service interface lists (alternate stripe), for example, #DDDDDD', 'type' => 'text', 'per_agent' => 1, @@ -4634,7 +4600,7 @@ and customer address. Include units.', { 'key' => 'selfservice-text_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML text color for the self-service interface, for example, #000000', 'type' => 'text', 'per_agent' => 1, @@ -4642,7 +4608,7 @@ and customer address. Include units.', { 'key' => 'selfservice-link_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML link color for the self-service interface, for example, #0000FF', 'type' => 'text', 'per_agent' => 1, @@ -4650,7 +4616,7 @@ and customer address. Include units.', { 'key' => 'selfservice-vlink_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML visited link color for the self-service interface, for example, #FF00FF', 'type' => 'text', 'per_agent' => 1, @@ -4658,7 +4624,7 @@ and customer address. Include units.', { 'key' => 'selfservice-hlink_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML hover link color for the self-service interface, for example, #808080', 'type' => 'text', 'per_agent' => 1, @@ -4666,7 +4632,7 @@ and customer address. Include units.', { 'key' => 'selfservice-alink_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML active (clicked) link color for the self-service interface, for example, #808080', 'type' => 'text', 'per_agent' => 1, @@ -4674,7 +4640,7 @@ and customer address. Include units.', { 'key' => 'selfservice-font', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML font CSS for the self-service interface, for example, 0.9em/1.5em Arial, Helvetica, Geneva, sans-serif', 'type' => 'text', 'per_agent' => 1, @@ -4682,7 +4648,7 @@ and customer address. Include units.', { 'key' => 'selfservice-no_logo', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Disable the logo in self-service', 'type' => 'checkbox', 'per_agent' => 1, @@ -4690,7 +4656,7 @@ and customer address. Include units.', { 'key' => 'selfservice-title_color', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML color for the self-service title, for example, #000000', 'type' => 'text', 'per_agent' => 1, @@ -4698,14 +4664,14 @@ and customer address. Include units.', { 'key' => 'selfservice-title_align', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML alignment for the self-service title, for example, center', 'type' => 'text', 'per_agent' => 1, }, { 'key' => 'selfservice-title_size', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML font size for the self-service title, for example, 3', 'type' => 'text', 'per_agent' => 1, @@ -4713,7 +4679,7 @@ and customer address. Include units.', { 'key' => 'selfservice-title_left_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Image used for the top of the menu in the self-service interface, in PNG format.', 'type' => 'image', 'per_agent' => 1, @@ -4721,7 +4687,7 @@ and customer address. Include units.', { 'key' => 'selfservice-title_right_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Image used for the top of the menu in the self-service interface, in PNG format.', 'type' => 'image', 'per_agent' => 1, @@ -4770,7 +4736,7 @@ and customer address. Include units.', { 'key' => 'selfservice-menu_bgcolor', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML color for the self-service menu, for example, #C0C0C0', 'type' => 'text', 'per_agent' => 1, @@ -4778,14 +4744,14 @@ and customer address. Include units.', { 'key' => 'selfservice-menu_fontsize', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'HTML font size for the self-service menu, for example, -1', 'type' => 'text', 'per_agent' => 1, }, { 'key' => 'selfservice-menu_nounderline', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Styles menu links in the self-service without underlining.', 'type' => 'checkbox', 'per_agent' => 1, @@ -4794,7 +4760,7 @@ and customer address. Include units.', { 'key' => 'selfservice-menu_top_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Image used for the top of the menu in the self-service interface, in PNG format.', 'type' => 'image', 'per_agent' => 1, @@ -4802,7 +4768,7 @@ and customer address. Include units.', { 'key' => 'selfservice-menu_body_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Repeating image used for the body of the menu in the self-service interface, in PNG format.', 'type' => 'image', 'per_agent' => 1, @@ -4810,7 +4776,7 @@ and customer address. Include units.', { 'key' => 'selfservice-menu_bottom_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Image used for the bottom of the menu in the self-service interface, in PNG format.', 'type' => 'image', 'per_agent' => 1, @@ -4825,14 +4791,14 @@ and customer address. Include units.', { 'key' => 'selfservice-login_banner_image', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Banner image shown on the login page, in PNG format.', 'type' => 'image', }, { 'key' => 'selfservice-login_banner_url', - 'section' => 'self-service', + 'section' => 'self-service_skinning', 'description' => 'Link for the login banner.', 'type' => 'text', }, @@ -4846,28 +4812,28 @@ and customer address. Include units.', { 'key' => 'signup-no_company', - 'section' => 'self-service', + 'section' => 'signup', 'description' => "Don't display a field for company name on signup.", 'type' => 'checkbox', }, { 'key' => 'signup-recommend_email', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Encourage the entry of an invoicing email address on signup.', 'type' => 'checkbox', }, { 'key' => 'signup-recommend_daytime', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Encourage the entry of a daytime phone number on signup.', 'type' => 'checkbox', }, { 'key' => 'signup-duplicate_cc-warn_hours', - 'section' => 'self-service', + 'section' => 'signup', 'description' => 'Issue a warning if the same credit card is used for multiple signups within this many hours.', 'type' => 'text', }, @@ -4984,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*.', @@ -5019,6 +4992,20 @@ and customer address. Include units.', }, { + 'key' => 'cdr-userfield_dnis_rewrite', + 'section' => 'telephony', + 'description' => 'If the CDR userfield contains "DNIS=" followed by a sequence of digits, use that as the destination number for the call.', + 'type' => 'checkbox', + }, + + { + 'key' => 'cdr-intl_to_domestic_rewrite', + 'section' => 'telephony', + 'description' => 'Strip the "011" international prefix from CDR destination numbers if the rest of the number is 7 digits or shorter, and so probably does not contain a country code.', + 'type' => 'checkbox', + }, + + { 'key' => 'cdr-gsm_tap3-sender', 'section' => 'telephony', 'description' => 'GSM TAP3 Sender network (5 letter code)', @@ -5027,7 +5014,7 @@ and customer address. Include units.', { 'key' => 'cust_pkg-show_autosuspend', - 'section' => 'UI', + 'section' => 'suspension', 'description' => 'Show package auto-suspend dates. Use with caution for now; can slow down customer view for large insallations.', 'type' => 'checkbox', }, @@ -5040,72 +5027,64 @@ and customer address. Include units.', }, { - 'key' => 'mc-outbound_packages', - 'section' => '', - 'description' => "Don't use this.", - 'type' => 'select-part_pkg', - 'multiple' => 1, - }, - - { 'key' => 'disable-cust-pkg_class', - 'section' => 'UI', + 'section' => 'packages', 'description' => 'Disable the two-step dropdown for selecting package class and package, and return to the classic single dropdown.', 'type' => 'checkbox', }, { 'key' => 'queued-max_kids', - 'section' => '', + 'section' => 'scalability', 'description' => 'Maximum number of queued processes. Defaults to 10.', 'type' => 'text', }, { 'key' => 'queued-sleep_time', - 'section' => '', + 'section' => 'telephony', 'description' => 'Time to sleep between attempts to find new jobs to process in the queue. Defaults to 10. Installations doing real-time CDR processing for prepaid may want to set it lower.', 'type' => 'text', }, { 'key' => 'queue-no_history', - 'section' => '', + 'section' => 'scalability', 'description' => "Don't recreate the h_queue and h_queue_arg tables on upgrades. This can save disk space for large installs, especially when using prepaid or multi-process billing. After turning this option on, drop the h_queue and h_queue_arg tables, run freeside-dbdef-create and restart Apache and Freeside.", 'type' => 'checkbox', }, { 'key' => 'cancelled_cust-noevents', - 'section' => 'billing', + 'section' => 'cancellation', 'description' => "Don't run events for cancelled customers", 'type' => 'checkbox', }, { 'key' => 'agent-invoice_template', - 'section' => 'invoicing', + 'section' => 'deprecated', 'description' => 'Enable display/edit of old-style per-agent invoice template selection', 'type' => 'checkbox', }, { 'key' => 'svc_broadband-manage_link', - 'section' => 'UI', + 'section' => 'wireless_broadband', 'description' => 'URL for svc_broadband "Manage Device" link. The following substitutions are available: $ip_addr and $mac_addr.', 'type' => 'text', }, { 'key' => 'svc_broadband-manage_link_text', - 'section' => 'UI', + 'section' => 'wireless_broadband', 'description' => 'Label for "Manage Device" link', 'type' => 'text', }, { 'key' => 'svc_broadband-manage_link_loc', - 'section' => 'UI', + 'section' => 'wireless_broadband', 'description' => 'Location for "Manage Device" link', 'type' => 'select', 'select_hash' => [ @@ -5116,7 +5095,7 @@ and customer address. Include units.', { 'key' => 'svc_broadband-manage_link-new_window', - 'section' => 'UI', + 'section' => 'wireless_broadband', 'description' => 'Open the "Manage Device" link in a new window', 'type' => 'checkbox', }, @@ -5124,21 +5103,21 @@ and customer address. Include units.', #more fine-grained, service def-level control could be useful eventually? { 'key' => 'svc_broadband-allow_null_ip_addr', - 'section' => '', + 'section' => 'wireless_broadband', 'description' => '', 'type' => 'checkbox', }, { 'key' => 'svc_hardware-check_mac_addr', - 'section' => '', #? + 'section' => 'services', 'description' => 'Require the "hardware address" field in hardware services to be a valid MAC address.', 'type' => 'checkbox', }, { 'key' => 'tax-report_groups', - 'section' => '', + 'section' => 'taxation', 'description' => 'List of grouping possibilities for tax names on reports, one per line, "label op value" (op can be = or !=).', 'type' => 'textarea', }, @@ -5151,13 +5130,6 @@ and customer address. Include units.', }, { - 'key' => 'tax-cust_exempt-groups-require_individual_nums', - 'section' => 'deprecated', - 'description' => 'Deprecated: see tax-cust_exempt-groups-number_requirement', - 'type' => 'checkbox', - }, - - { 'key' => 'tax-cust_exempt-groups-num_req', 'section' => 'taxation', 'description' => 'When using tax-cust_exempt-groups, control whether individual tax exemption numbers are required for exemption from different taxes.', @@ -5170,7 +5142,7 @@ and customer address. Include units.', { 'key' => 'tax-round_per_line_item', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Calculate tax and round to the nearest cent for each line item, rather than for the whole invoice.', 'type' => 'checkbox', }, @@ -5201,28 +5173,28 @@ and customer address. Include units.', { 'key' => 'rt-crontool', - 'section' => '', + 'section' => 'ticketing', 'description' => 'Enable the RT CronTool extension.', 'type' => 'checkbox', }, { 'key' => 'pkg-balances', - 'section' => 'billing', + 'section' => 'packages', 'description' => 'Enable per-package balances.', 'type' => 'checkbox', }, { 'key' => 'pkg-addon_classnum', - 'section' => 'billing', + 'section' => 'packages', 'description' => 'Enable the ability to restrict additional package orders based on package class.', 'type' => 'checkbox', }, { 'key' => 'cust_main-edit_signupdate', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Enable manual editing of the signup date.', 'type' => 'checkbox', }, @@ -5272,21 +5244,21 @@ and customer address. Include units.', { 'key' => 'svc_domain-edit_domain', - 'section' => '', + 'section' => 'services', 'description' => 'Enable domain renaming', 'type' => 'checkbox', }, { 'key' => 'enable_legacy_prepaid_income', - 'section' => '', + 'section' => 'reporting', 'description' => "Enable legacy prepaid income reporting. Only useful when you have imported pre-Freeside packages with longer-than-monthly duration, and need to do prepaid income reporting on them before they've been invoiced the first time.", 'type' => 'checkbox', }, { 'key' => 'cust_main-exports', - 'section' => '', + 'section' => 'API', 'description' => 'Export(s) to call on cust_main insert, modification and deletion.', 'type' => 'select-sub', 'multiple' => 1, @@ -5296,7 +5268,7 @@ and customer address. Include units.', my @part_export = map { qsearch( 'part_export', {exporttype => $_ } ) } keys %{FS::part_export::export_info('cust_main')}; - map { $_->exportnum => $_->exporttype.' to '.$_->machine } @part_export; + map { $_->exportnum => $_->exportname } @part_export; }, 'option_sub' => sub { require FS::Record; @@ -5305,7 +5277,7 @@ and customer address. Include units.', 'part_export', { 'exportnum' => shift } ); $part_export - ? $part_export->exporttype.' to '.$part_export->machine + ? $part_export->exportname : ''; }, }, @@ -5313,8 +5285,8 @@ and customer address. Include units.', #false laziness w/above options_sub and option_sub { 'key' => 'cust_location-exports', - 'section' => '', - 'description' => 'Export(s) to call on cust_location insert, modification and deletion.', + 'section' => 'API', + 'description' => 'Export(s) to call on cust_location insert or modification', 'type' => 'select-sub', 'multiple' => 1, 'options_sub' => sub { @@ -5323,7 +5295,7 @@ and customer address. Include units.', my @part_export = map { qsearch( 'part_export', {exporttype => $_ } ) } keys %{FS::part_export::export_info('cust_location')}; - map { $_->exportnum => $_->exporttype.' to '.$_->machine } @part_export; + map { $_->exportnum => $_->exportname } @part_export; }, 'option_sub' => sub { require FS::Record; @@ -5332,7 +5304,7 @@ and customer address. Include units.', 'part_export', { 'exportnum' => shift } ); $part_export - ? $part_export->exporttype.' to '.$part_export->machine + ? $part_export->exportname : ''; }, }, @@ -5368,14 +5340,14 @@ and customer address. Include units.', { 'key' => 'part_pkg-default_suspend_bill', - 'section' => 'billing', + 'section' => 'suspension', 'description' => 'Default the "Continue recurring billing while suspended" flag to on for new package definitions.', 'type' => 'checkbox', }, { 'key' => 'qual-alt_address_format', - 'section' => 'UI', + 'section' => 'addresses', 'description' => 'Enable the alternate address format (location type, number, and kind) for qualifications.', 'type' => 'checkbox', }, @@ -5396,7 +5368,7 @@ and customer address. Include units.', { 'key' => 'note-classes', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Use customer note classes', 'type' => 'select', 'select_hash' => [ @@ -5408,7 +5380,7 @@ and customer address. Include units.', { 'key' => 'svc_acct-cf_privatekey-message', - 'section' => '', + 'section' => 'development', 'description' => 'For internal use: HTML displayed when cf_privatekey field is set.', 'type' => 'textarea', }, @@ -5429,14 +5401,14 @@ and customer address. Include units.', { 'key' => 'svc_phone-did-summary', - 'section' => 'invoicing', + 'section' => 'telephony', 'description' => 'Experimental feature to enable DID activity summary on invoices, showing # DIDs activated/deactivated/ported-in/ported-out and total minutes usage, covering period since last invoice.', 'type' => 'checkbox', }, { 'key' => 'svc_acct-usage_seconds', - 'section' => 'invoicing', + 'section' => 'RADIUS', 'description' => 'Enable calculation of RADIUS usage time for invoices. You must modify your template to display this information.', 'type' => 'checkbox', }, @@ -5470,7 +5442,7 @@ and customer address. Include units.', { 'key' => 'cust_bill-no_recipients-error', - 'section' => 'invoicing', + 'section' => 'invoice_email', 'description' => 'For customers with no invoice recipients, throw a job queue error rather than the default behavior of emailing the invoice to the invoice_from address.', 'type' => 'checkbox', }, @@ -5521,28 +5493,21 @@ and customer address. Include units.', { 'key' => 'disable_payauto_default', - 'section' => 'UI', + 'section' => 'payments', 'description' => 'Disable the "Charge future payments to this (card|check) automatically" checkbox from defaulting to checked.', 'type' => 'checkbox', }, { 'key' => 'payment-history-report', - 'section' => 'UI', - 'description' => 'Show a link to the raw database payment history report in the Reports menu. DO NOT ENABLE THIS for modern installations.', - 'type' => 'checkbox', - }, - - { - 'key' => 'svc_broadband-require-nw-coordinates', 'section' => 'deprecated', - 'description' => 'Deprecated; see geocode-require_nw_coordinates instead', + 'description' => 'Show a link to the raw database payment history report in the Reports menu. DO NOT ENABLE THIS for modern installations.', 'type' => 'checkbox', }, { 'key' => 'cust-edit-alt-field-order', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'An alternate ordering of fields for the New Customer and Edit Customer screens.', 'type' => 'checkbox', }, @@ -5556,7 +5521,7 @@ and customer address. Include units.', { 'key' => 'available-locales', - 'section' => '', + 'section' => 'localization', 'description' => 'Limit available locales (employee preferences, per-customer locale selection, etc.) to a particular set.', 'type' => 'select-sub', 'multiple' => 1, @@ -5569,14 +5534,14 @@ and customer address. Include units.', { 'key' => 'cust_main-require_locale', - 'section' => 'UI', + 'section' => 'localization', 'description' => 'Require an explicit locale to be chosen for new customers.', 'type' => 'checkbox', }, { 'key' => 'translate-auto-insert', - 'section' => '', + 'section' => 'localization', 'description' => 'Auto-insert untranslated strings for selected non-en_US locales with their default/en_US values. Do not turn this on unless translating the interface into a new language. Restart Apache after changing.', 'type' => 'select', 'multiple' => 1, @@ -5585,7 +5550,7 @@ and customer address. Include units.', { 'key' => 'svc_acct-tower_sector', - 'section' => '', + 'section' => 'services', 'description' => 'Track tower and sector for svc_acct (account) services.', 'type' => 'checkbox', }, @@ -5630,6 +5595,13 @@ and customer address. Include units.', 'rate_low' => 'Lowest rate first', ], }, + + { + 'key' => 'cdr-lrn_lookup', + 'section' => 'telephony', + 'description' => 'Look up LRNs of destination numbers for exact matching to the terminating carrier. This feature requires a Freeside support contract.', + 'type' => 'checkbox', + }, { 'key' => 'brand-agent', @@ -5698,7 +5670,7 @@ and customer address. Include units.', { 'key' => 'spreadsheet_format', - 'section' => 'UI', + 'section' => 'reporting', 'description' => 'Default format for spreadsheet download.', 'type' => 'select', 'select_hash' => [ @@ -5709,7 +5681,7 @@ and customer address. Include units.', { 'key' => 'report-cust_pay-select_time', - 'section' => 'UI', + 'section' => 'reporting', 'description' => 'Enable time selection on payment and refund reports.', 'type' => 'checkbox', }, @@ -5731,7 +5703,7 @@ and customer address. Include units.', { 'key' => 'allow_invalid_cards', - 'section' => '', + 'section' => 'development', 'description' => 'Accept invalid credit card numbers. Useful for testing with fictitious customers. There is no good reason to enable this in production.', 'type' => 'checkbox', }, @@ -5791,22 +5763,22 @@ and customer address. Include units.', { 'key' => 'part_pkg-term_discounts', - 'section' => 'billing', + 'section' => 'packages', 'description' => 'Enable the term discounts feature. Recommended to keep turned off unless actually using - not well optimized for large installations.', 'type' => 'checkbox', }, { 'key' => 'prepaid-never_renew', - 'section' => 'billing', + 'section' => 'packages', 'description' => 'Prepaid packages never renew.', 'type' => 'checkbox', }, { 'key' => 'agent-disable_counts', - 'section' => 'UI', - 'description' => 'On the agent browse page, disable the customer and package counts. Typically used for very large databases when this page takes too long to render.', + 'section' => 'scalability', + 'description' => 'On the agent browse page, disable the customer and package counts. Typically used for very large installs when this page takes too long to render.', 'type' => 'checkbox', }, @@ -5823,21 +5795,21 @@ and customer address. Include units.', { 'key' => 'old_fcc_report', - 'section' => '', + 'section' => 'deprecated', 'description' => 'Use the old (pre-2014) FCC Form 477 report format.', 'type' => 'checkbox', }, { 'key' => 'cust_main-default_commercial', - 'section' => 'UI', + 'section' => 'customer_fields', 'description' => 'Default for new customers is commercial rather than residential.', 'type' => 'checkbox', }, { 'key' => 'default_appointment_length', - 'section' => 'UI', + 'section' => 'appointments', 'description' => 'Default appointment length, in minutes (30 minute granularity).', 'type' => 'text', }, diff --git a/FS/FS/ConfDefaults.pm b/FS/FS/ConfDefaults.pm index b24a300f9..2fa834439 100644 --- a/FS/FS/ConfDefaults.pm +++ b/FS/FS/ConfDefaults.pm @@ -56,26 +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) | Current Balance' => + 'custnum | 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 | 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# | 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# | 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# | 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' => - '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 | 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/IP_Mixin.pm b/FS/FS/IP_Mixin.pm index d4920468c..beb41d290 100644 --- a/FS/FS/IP_Mixin.pm +++ b/FS/FS/IP_Mixin.pm @@ -153,14 +153,14 @@ sub assign_ip_addr { # don't exit early on assigning a free address--check the rest of # the blocks to see if the current address is in one of them. if (!$new_addr) { - $new_addr = $block->next_free_addr->addr; + $new_addr = $block->next_free_addr; $new_block = $block; } } return 'No IP address available on this router' unless $new_addr; - $self->ip_addr($new_addr); + $self->ip_addr($new_addr->addr); $self->addr_block($new_block); ''; } 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 3a00f427c..245bdea88 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -91,7 +91,7 @@ if ( -e $addl_handler_use_file ) { use Excel::Writer::XLSX; #use Excel::Writer::XLSX::Utility; #redundant with above - use Business::CreditCard 0.30; #for mask-aware cardtype() + use Business::CreditCard 0.36; #for best-effort cardtype() (60xx as Discover) use NetAddr::IP; use Net::MAC::Vendor; use Net::Ping; @@ -131,7 +131,8 @@ if ( -e $addl_handler_use_file ) { use FS::Conf; use FS::CGI qw(header menubar table itable ntable idiot eidiot myexit http_header); - use FS::UI::Web qw(svc_url random_id); + use FS::UI::Web qw(svc_url random_id + get_page_pref set_page_pref); use FS::UI::Web::small_custview qw(small_custview); use FS::UI::bytecount; use FS::UI::REST qw( rest_auth rest_uri_remain encode_rest ); @@ -145,7 +146,8 @@ if ( -e $addl_handler_use_file ) { use FS::Report::Table; use FS::Report::Table::Monthly; use FS::Report::Table::Daily; - use FS::Report::Tax; + use FS::Report::Tax::ByName; + use FS::Report::Tax::All; use FS::TicketSystem; use FS::NetworkMonitoringSystem; use FS::Tron qw( tron_lint ); @@ -409,6 +411,10 @@ if ( -e $addl_handler_use_file ) { use FS::svc_fiber; use FS::fiber_olt; 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 ) { @@ -570,7 +576,7 @@ if ( -e $addl_handler_use_file ) { } # end package HTML::Mason::Commands; -=head1 SUBROUTINE +=head1 SUBROUTINES =over 4 @@ -666,6 +672,35 @@ sub mason_interps { } +=item child_init + +Per-process Apache child initialization code. + +Calls srand() to re-seed Perl's PRNG so that multiple children do not generate +the same "random" numbers. + +Works around a Net::SSLeay connection error by creating and deleting an SSL +context, so subsequent connections do not error out with a CTX_new (900 NET OR +SSL ERROR). See http://bugs.debian.org/830152 + +=cut + +sub child_init { + #my ($pool, $server) = @_; #the child process pool (APR::Pool) and the server object (Apache2::ServerRec). + + srand(); + + #{ + use Net::SSLeay; + package Net::SSLeay; + initialize(); + my $bad_ctx = new_x_ctx(); + while ( ERR_get_error() ) {}; #print_errs('CTX_new'); + CTX_free($bad_ctx); + #} + +} + =back =head1 BUGS diff --git a/FS/FS/Mason/StandaloneRequest.pm b/FS/FS/Mason/StandaloneRequest.pm index a5e4dcb2a..e34a35310 100644 --- a/FS/FS/Mason/StandaloneRequest.pm +++ b/FS/FS/Mason/StandaloneRequest.pm @@ -20,4 +20,13 @@ sub new { } +# fake this up for UI testing +sub redirect { + my $self = shift; + if (scalar(@_)) { + $self->{_redirect} = shift; + } + return $self->{_redirect}; +} + 1; diff --git a/FS/FS/Misc/DateTime.pm b/FS/FS/Misc/DateTime.pm index 56baec3ed..08cf9a945 100644 --- a/FS/FS/Misc/DateTime.pm +++ b/FS/FS/Misc/DateTime.pm @@ -81,7 +81,7 @@ date and time. =cut sub iso8601 { - time2str('%Y-%m-%dT%T', @_); + time2str('%Y-%m-%dT%T', shift); } =back diff --git a/FS/FS/Misc/Geo.pm b/FS/FS/Misc/Geo.pm index aa4e55e36..92490bb3b 100644 --- a/FS/FS/Misc/Geo.pm +++ b/FS/FS/Misc/Geo.pm @@ -6,6 +6,7 @@ use vars qw( $DEBUG @EXPORT_OK $conf ); use LWP::UserAgent; use HTTP::Request; use HTTP::Request::Common qw( GET POST ); +use IO::Socket::SSL; use HTML::TokeParser; use Cpanel::JSON::XS; use URI::Escape 3.31; @@ -642,6 +643,50 @@ sub standardize_melissa { } } +sub standardize_freeside { + my $class = shift; + my $location = shift; + + my $url = 'https://ws.freeside.biz/normalize'; + + #free freeside.biz normalization only for US + if ( $location->{country} ne 'US' ) { + # soft failure + #why? something else could have cleaned it $location->{addr_clean} = ''; + return $location; + } + + my $ua = LWP::UserAgent->new( + 'ssl_opts' => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + }, + ); + my $response = $ua->request( POST $url, [ + 'support-key' => scalar($conf->config('support-key')), + %$location, + ]); + + die "Address normalization error: ". $response->message + unless $response->is_success; + + local $@; + my $content = eval { decode_json($response->content) }; + if ( $@ ) { + warn $response->content; + die "Address normalization JSON error : $@\n"; + } + + die $content->{error}."\n" + if $content->{error}; + + { 'addr_clean' => 'Y', + map { $_ => $content->{$_} } + qw( address1 address2 city state zip country ) + }; + +} + =back =cut diff --git a/FS/FS/Password_Mixin.pm b/FS/FS/Password_Mixin.pm index da80cd27f..122e3fd83 100644 --- a/FS/FS/Password_Mixin.pm +++ b/FS/FS/Password_Mixin.pm @@ -14,8 +14,6 @@ FS::UID->install_callback( sub { $conf = FS::Conf->new; }); -our @pw_set; - our $me = '[' . __PACKAGE__ . ']'; our $BLOWFISH_COST = 10; @@ -47,8 +45,8 @@ sub is_password_allowed { # basic checks using Data::Password; # options for Data::Password - $DICTIONARY = 4; # minimum length of disallowed words - $MINLEN = $conf->config('passwordmin') || 6; + $DICTIONARY = 0; # minimum length of disallowed words, false value disables dictionary checking + $MINLEN = $conf->config('passwordmin') || 8; $MAXLEN = $conf->config('passwordmax') || 12; $GROUPS = 4; # must have all 4 'character groups': numbers, symbols, uppercase, lowercase # other options use the defaults listed below: @@ -57,9 +55,23 @@ sub is_password_allowed { # # lists of disallowed words # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words ); + # first, no dictionary checking but require 4 char groups my $error = IsBadPassword($password); - $error = 'must contain at least one each of numbers, symbols, and lowercase and uppercase letters' - if $error eq 'contains less than 4 character groups'; # avoid confusion + + # but they can get away with 3 char groups, so long as they're not using a word + if ($error eq 'contains less than 4 character groups') { + $DICTIONARY = 4; # default from Data::Password is 5 + $GROUPS = 3; + $error = IsBadPassword($password); + # take note--we never actually report dictionary word errors; + # 4 char groups is the rule, 3 char groups and no dictionary words is an acceptable exception + $error = 'should contain at least one each of numbers, symbols, lowercase and uppercase letters' + if $error; + } + + # maybe also at some point add an exception for any passwords of sufficient length, + # see https://xkcd.com/936/ + $error = 'Invalid password - ' . $error if $error; return $error if $error; @@ -262,27 +274,19 @@ sub _blowfishcrypt { =item pw_set -Returns the list of characters allowed in random passwords (from the -C<password-generated-characters> config). +Returns the list of characters allowed in random passwords. This is now +hardcoded. =cut sub pw_set { - my $class = shift; - if (!@pw_set) { - my $pw_set = $conf->config('password-generated-characters'); - $pw_set =~ s/\s//g; # don't ever allow whitespace - if ( $pw_set =~ /[[:lower:]]/ - && $pw_set =~ /[[:upper:]]/ - && $pw_set =~ /[[:digit:]]/ - && $pw_set =~ /[[:punct:]]/ ) { - @pw_set = split('', $pw_set); - } else { - warn "password-generated-characters set is insufficient; using default."; - @pw_set = split('', 'abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789()#.,'); - } - } - return @pw_set; + + # ASCII alphabet, minus easily confused stuff (l, o, O, 0, 1) + # and plus some "safe" punctuation + split('', + 'abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789#.,[]-_=+' + ); + } =back diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm index a117b7477..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 @@ -2152,6 +2153,7 @@ sub batch_import { #my $job = $param->{job}; my $line; my $imported = 0; + my $unique_skip = 0; #lines skipped because they're already in the system my( $last, $min_sec ) = ( time, 5 ); #progressbar foo while (1) { @@ -2254,6 +2256,7 @@ sub batch_import { } last if exists( $param->{skiprow} ); } + $unique_skip++ if $param->{unique_skip}; #line is already in the system next if exists( $param->{skiprow} ); if ( $preinsert_callback ) { @@ -2299,7 +2302,8 @@ sub batch_import { unless ( $imported || $param->{empty_ok} ) { $dbh->rollback if $oldAutoCommit; - return "Empty file!"; + # freeside-cdr-conexiant-import is sensitive to the text of this message + return $unique_skip ? "All records in file were previously imported" : "Empty file!"; } $dbh->commit or die $dbh->errstr if $oldAutoCommit; @@ -2910,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 ( @@ -3217,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/Report/Tax/All.pm b/FS/FS/Report/Tax/All.pm new file mode 100644 index 000000000..26dbf5f0f --- /dev/null +++ b/FS/FS/Report/Tax/All.pm @@ -0,0 +1,110 @@ +package FS::Report::Tax::All; + +use strict; +use vars qw($DEBUG); +use FS::Record qw(dbh qsearch qsearchs group_concat_sql); +use FS::Report::Tax::ByName; +use Date::Format qw( time2str ); + +use Data::Dumper; + +$DEBUG = 0; + +=item report OPTIONS + +Constructor. Generates a tax report using the internal tax rate system, +showing all taxes, broken down by tax name and country. + +Required parameters: +- beginning, ending: the date range as Unix timestamps. + +Optional parameters: +- debug: sets the debug level. 1 will warn the data collected for the report; +2 will also warn all of the SQL statements. + +=cut + +# because there's not yet a "DBIx::DBSchema::View"... + +sub report { + my $class = shift; + my %opt = @_; + + $DEBUG ||= $opt{debug}; + + my($beginning, $ending) = @opt{'beginning', 'ending'}; + + # figure out which reports we need to run + my @taxname_and_country = qsearch({ + table => 'cust_main_county', + select => 'country, taxname', + hashref => { + tax => { op => '>', value => '0' } + }, + order_by => 'GROUP BY country, taxname ORDER BY country, taxname', + }); + my @table; + foreach (@taxname_and_country) { + my $taxname = $_->taxname || 'Tax'; + my $country = $_->country; + my $report = FS::Report::Tax::ByName->report( + %opt, + taxname => $taxname, + country => $country, + total_only => 1, + ); + # will have only one total row (should be only one row at all) + my ($total_row) = grep { $_->{total} } $report->table; + $total_row->{total} = 0; # but in this context it's a detail row + $total_row->{taxname} = $taxname; + $total_row->{country} = $country; + $total_row->{label} = "$country - $taxname"; + push @table, $total_row; + } + my $self = bless { + 'opt' => \%opt, + 'table' => \@table, + }, $class; + + $self; +} + +sub opt { + my $self = shift; + $self->{opt}; +} + +sub data { + my $self = shift; + $self->{data}; +} + +# sub fetchall_array... + +sub table { + my $self = shift; + @{ $self->{table} }; +} + +sub title { + my $self = shift; + my $string = ''; + if ( $self->{opt}->{agentnum} ) { + my $agent = qsearchs('agent', { agentnum => $self->{opt}->{agentnum} }); + $string .= $agent->agent . ' '; + } + $string .= 'Tax Report: '; # XXX localization + if ( $self->{opt}->{beginning} ) { + $string .= time2str('%h %o %Y ', $self->{opt}->{beginning}); + } + $string .= 'through '; + if ( $self->{opt}->{ending} and $self->{opt}->{ending} < 4294967295 ) { + $string .= time2str('%h %o %Y', $self->{opt}->{ending}); + } else { + $string .= 'now'; + } + $string .= ' - all taxes'; + return $string; +} + +1; diff --git a/FS/FS/Report/Tax.pm b/FS/FS/Report/Tax/ByName.pm index f1f6be38e..88695b909 100644 --- a/FS/FS/Report/Tax.pm +++ b/FS/FS/Report/Tax/ByName.pm @@ -1,4 +1,4 @@ -package FS::Report::Tax; +package FS::Report::Tax::ByName; use strict; use vars qw($DEBUG); @@ -9,10 +9,12 @@ use Data::Dumper; $DEBUG = 0; -=item report_internal OPTIONS +=item report OPTIONS Constructor. Generates a tax report using the internal tax rate system -(L<FS::cust_main_county>). +(L<FS::cust_main_county>), showing all taxes with a specified tax name, +broken down by state/county. Optionally, the taxes can be broken down further +by city/district, tax class, or package class. Required parameters: @@ -22,21 +24,22 @@ Required parameters: Optional parameters: - agentnum: limit to this agentnum.num. -- breakdown: hashref of the fields to group by. Keys can be 'city', 'district', - 'pkgclass', or 'taxclass'; values should be true. +- breakdown: hashref of the fields to group by. Keys can be 'city', +'district', 'pkgclass', or 'taxclass'; values should be true. +- total_only: don't run the tax group queries, only the totals queries. +Returns one row, except in the unlikely event you're using breakdown by +package class. - debug: sets the debug level. 1 will warn the data collected for the report; - 2 will also warn all of the SQL statements. +2 will also warn all of the SQL statements. =cut -sub report_internal { +sub report { my $class = shift; my %opt = @_; $DEBUG ||= $opt{debug}; - my $conf = new FS::Conf; - my($beginning, $ending) = @opt{'beginning', 'ending'}; my ($taxname, $country, %breakdown); diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index a47212045..29f1b3191 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -201,11 +201,9 @@ sub dbdef_dist { grep { ! /^(clientapi|access_user)_session/ && ! /^h_/ && ! /^log(_context)?$/ - && ! /^legacy_cust_history$/ + && ! /^(legacy_cust_history|cacti_page|template_image|access_user_log)$/ && ( ! /^queue(_arg|_depend|_stat)?$/ || ! $opt->{'queue-no_history'} ) && ! $tables_hashref_torrus->{$_} - && ! /^cacti_page$/ - && ! /^template_image$/ } $dbdef->tables ) { @@ -925,12 +923,13 @@ sub tables_hashref { '_date', @date_type, '', '', 'status', 'varchar', '', $char_d, '', '', 'statustext', 'text', 'NULL', '', '', '', + 'no_action', 'char', 'NULL', 1, '', '', ], 'primary_key' => 'eventnum', #no... there are retries now #'unique' => [ [ 'eventpart', 'invnum' ] ], 'unique' => [], 'index' => [ ['eventpart'], ['tablenum'], ['status'], - ['statustext'], ['_date'], + ['statustext'], ['_date'], ['no_action'], ], 'foreign_keys' => [ { columns => [ 'eventpart' ], @@ -1362,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', @@ -1397,6 +1397,10 @@ sub tables_hashref { table => 'cust_pkg', references => [ 'pkgnum' ], }, + { columns => [ 'commission_invnum' ], + table => 'cust_bill', + references => [ 'invnum' ], + }, ], }, @@ -1418,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, '', '', @@ -1457,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' ], @@ -1694,7 +1703,7 @@ sub tables_hashref { 'weight', 'int', 'NULL', '', '', '', 'payby', 'char', '', 4, '', '', 'payinfo', 'varchar', 'NULL', 512, '', '', - 'cardtype', 'varchar', 'NULL', $char_d, '', '', + 'paycardtype', 'varchar', 'NULL', $char_d, '', '', 'paycvv', 'varchar', 'NULL', 512, '', '', 'paymask', 'varchar', 'NULL', $char_d, '', '', #'paydate', @date_type, '', '', @@ -2444,6 +2453,7 @@ sub tables_hashref { 'usernum', 'int', 'NULL', '', '', '', 'payby', 'char', '', 4, '', '', 'payinfo', 'varchar', 'NULL', 512, '', '', + 'paycardtype', 'varchar', 'NULL', $char_d, '', '', 'paymask', 'varchar', 'NULL', $char_d, '', '', 'paydate', 'varchar', 'NULL', 10, '', '', 'paybatch', 'varchar', 'NULL', $char_d, '', '',#for auditing purposes @@ -2464,7 +2474,7 @@ sub tables_hashref { 'gatewaynum', 'int', 'NULL', '', '', '', # payment_gateway FK 'processor', 'varchar', 'NULL', $char_d, '', '', # module name 'auth', 'varchar', 'NULL', 16, '', '', # CC auth number - 'order_number','varchar', 'NULL', $char_d, '', '', # transaction number + 'order_number','varchar', 'NULL', 256, '', '', # transaction number ], 'primary_key' => 'paynum', #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ], @@ -2501,7 +2511,8 @@ sub tables_hashref { 'usernum', 'int', 'NULL', '', '', '', 'payby', 'char', '', 4, '', '', 'payinfo', 'varchar', 'NULL', 512, '', '', - 'paymask', 'varchar', 'NULL', $char_d, '', '', + 'paycardtype', 'varchar', 'NULL', $char_d, '', '', + 'paymask', 'varchar', 'NULL', $char_d, '', '', #'paydate' ? 'paybatch', 'varchar', 'NULL', $char_d, '', '', #for auditing purposes. 'closed', 'char', 'NULL', 1, '', '', @@ -2518,7 +2529,7 @@ sub tables_hashref { 'gatewaynum', 'int', 'NULL', '', '', '', # payment_gateway FK 'processor', 'varchar', 'NULL', $char_d, '', '', # module name 'auth', 'varchar', 'NULL', 16, '', '', # CC auth number - 'order_number','varchar', 'NULL', $char_d, '', '', # transaction number + 'order_number','varchar', 'NULL', 256, '', '', # transaction number #void fields 'void_date', @date_type, '', '', @@ -3060,7 +3071,8 @@ sub tables_hashref { # be index into payby # table eventually 'payinfo', 'varchar', 'NULL', 512, '', '', #see cust_main above - 'paymask', 'varchar', 'NULL', $char_d, '', '', + 'paycardtype', 'varchar', 'NULL', $char_d, '', '', + 'paymask', 'varchar', 'NULL', $char_d, '', '', 'paybatch', 'varchar', 'NULL', $char_d, '', '', 'closed', 'char', 'NULL', 1, '', '', 'source_paynum', 'int', 'NULL', '', '', '', # link to cust_payby, to prevent unapply of gateway-generated refunds @@ -3592,10 +3604,11 @@ sub tables_hashref { 'refnum', 'serial', '', '', '', '', 'referral', 'varchar', '', $char_d, '', '', 'disabled', 'char', 'NULL', 1, '', '', - 'agentnum', 'int', 'NULL', '', '', '', + 'agentnum', 'int', 'NULL', '', '', '', + 'title', 'varchar', 'NULL', $char_d, '', '', ], 'primary_key' => 'refnum', - 'unique' => [], + 'unique' => [ ['agentnum', 'title'] ], 'index' => [ ['disabled'], ['agentnum'], ], 'foreign_keys' => [ { columns => [ 'agentnum' ], @@ -3682,6 +3695,24 @@ sub tables_hashref { ], }, + 'part_svc_msgcat' => { + 'columns' => [ + 'svcpartmsgnum', 'serial', '', '', '', '', + 'svcpart', 'int', '', '', '', '', + 'locale', 'varchar', '', 16, '', '', + 'svc', 'varchar', '', $char_d, '', '', + ], + 'primary_key' => 'svcpartmsgnum', + 'unique' => [ [ 'svcpart', 'locale' ] ], + 'index' => [], + 'foreign_keys' => [ + { columns => [ 'svcpart' ], + table => 'part_svc', + }, + ], + }, + + #(this should be renamed to part_pop) 'svc_acct_pop' => { 'columns' => [ @@ -3870,9 +3901,12 @@ sub tables_hashref { 'unique' => [], 'index' => [ ['svcnum', 'transaction_id'] ], 'foreign_keys' => [ - { columns => [ 'svcnum' ], - table => 'svc_acct', #'cust_svc', - }, + # problems w/deleted services, and as per below, this + # is our internal hack, not a customer-facing feature + #{ columns => [ 'svcnum' ], + # table => 'svc_acct', #'cust_svc', + #}, + # 1. RT tables aren't part of our data structure, so # we can't make sure Queue is created already # 2. This is our internal hack for time tracking, not @@ -4455,6 +4489,26 @@ sub tables_hashref { ], }, + 'export_cust_svc' => { + 'columns' => [ + 'exportcustsvcnum', 'serial', '', '', '', '', + 'exportnum', 'int', '', '', '', '', + 'svcnum', 'int', '', '', '', '', + 'remoteid', 'varchar', '', 512, '', '', + ], + 'primary_key' => 'exportcustsvcnum', + 'unique' => [ [ 'exportnum', 'svcnum' ] ], + 'index' => [ [ 'exportnum', 'svcnum' ] ], + 'foreign_keys' => [ + { columns => [ 'exportnum' ], + table => 'part_export', + }, + { columns => [ 'svcnum' ], + table => 'cust_svc', + }, + ], + }, + 'export_device' => { 'columns' => [ 'exportdevicenum' => 'serial', '', '', '', '', @@ -4825,9 +4879,16 @@ sub tables_hashref { 'freq_mhz', 'int', 'NULL', '', '', '', 'direction', 'int', 'NULL', '', '', '', 'width', 'int', 'NULL', '', '', '', - #downtilt etc? rfpath has profile files for devices/antennas you upload? 'sector_range', 'decimal', 'NULL', '', '', '', #? - ], + 'downtilt', 'decimal', 'NULL', '', '', '', + 'v_width', 'int', 'NULL', '', '', '', + 'margin', 'decimal', 'NULL', '', '', '', + 'image', 'blob', 'NULL', '', '', '', + 'west', 'decimal', 'NULL', '10,7', '', '', + 'east', 'decimal', 'NULL', '10,7', '', '', + 'south', 'decimal', 'NULL', '10,7', '', '', + 'north', 'decimal', 'NULL', '10,7', '', '', + ], 'primary_key' => 'sectornum', 'unique' => [ [ 'towernum', 'sectorname' ], [ 'ip_addr' ], ], 'index' => [ [ 'towernum' ] ], @@ -5486,6 +5547,10 @@ sub tables_hashref { 'rated_ratename', 'varchar', 'NULL', $char_d, '', '', 'rated_cost', 'decimal', 'NULL', '10,4', '', '', + # real endpoints of the call + 'src_lrn', 'varchar', 'NULL', '15', '', '', + 'dst_lrn', 'varchar', 'NULL', '15', '', '', + 'carrierid', 'bigint', 'NULL', '', '', '', # service it was matched to @@ -5548,6 +5613,7 @@ sub tables_hashref { 'rated_price', 'decimal', 'NULL', '10,4', '', '', 'rated_seconds', 'int', 'NULL', '', '', '', 'rated_minutes', 'double precision', 'NULL', '', '', '', + 'rated_granularity','int', 'NULL', '', '', '', 'status', 'varchar', 'NULL', 32, '', '', 'svcnum', 'int', 'NULL', '', '', '', ], @@ -5794,6 +5860,26 @@ sub tables_hashref { 'index' => [ ['usernum'], ['path'], ['_date'] ], }, + 'access_user_page_pref' => { + 'columns' => [ + 'prefnum' => 'serial', '', '', '', '', + 'usernum' => 'int', '', '', '', '', + 'path' => 'text', '', '', '', '', + 'tablenum' => 'int', 'NULL', '', '', '', + '_date' => @date_type, '', '', + 'prefname' => 'varchar', '', $char_d, '', '', + 'prefvalue' => 'text', '', '', '', '', + ], + 'primary_key' => 'prefnum', + 'unique' => [ [ 'usernum', 'path', 'tablenum', 'prefname' ] ], + 'index' => [], + 'foreign_keys' => [ + { columns => [ 'usernum' ], + table => 'access_user' + }, + ], + }, + 'sched_item' => { 'columns' => [ 'itemnum', 'serial', '', '', '', '', @@ -6585,6 +6671,7 @@ sub tables_hashref { 'min_level', 'int', 'NULL', '', '', '', 'msgnum', 'int', '', '', '', '', 'to_addr', 'varchar', 'NULL', 255, '', '', + 'context_height', 'int', 'NULL', '', '', '', ], 'primary_key' => 'logemailnum', 'unique' => [], @@ -7323,6 +7410,79 @@ sub tables_hashref { ], }, + 'webservice_log' => { + 'columns' => [ + 'webservicelognum', 'serial', '', '', '', '', #big? hubrus + 'svcnum', 'int', 'NULL', '', '', '', #just in case + 'custnum', 'int', '', '', '', '', + 'method', 'varchar', '', $char_d, '', '', + 'quantity', 'int', '', '', '', '', #i.e. pages + '_date', @date_type, '', '', + 'status', 'varchar', 'NULL', $char_d, '', '', + 'rated_price', 'decimal', 'NULL', '10,2', '', '', + ], + 'primary_key' => 'webservicelognum', + 'unique' => [], + 'index' => [ ['custnum'], ['status'] ], + 'foreign_keys' => [ + { columns => [ 'custnum' ], + table => 'cust_main', + }, + #no FK on svcnum... we don't want to purge these on + # service deletion + ], + }, + + 'rt_field_charge' => { + 'columns' => [ + 'rtfieldchargenum', 'serial', '', '', '', '', + 'pkgnum', 'int', '', '', '', '', + 'ticketid', 'int', '', '', '', '', + 'rate', @money_type, '', '', + 'units', 'decimal', '', '10,4', '', '', + 'charge', @money_type, '', '', + '_date', @date_type, '', '', + ], + 'primary_key' => 'rtfieldchargenum', + 'unique' => [], + 'index' => [ ['pkgnum', 'ticketid'] ], + 'foreign_keys' => [ + { columns => [ 'pkgnum' ], + table => 'cust_pkg', + }, + ], + }, + + '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 a9b32d133..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; @@ -66,7 +68,7 @@ sub taxline { my $taxnum = $tax_object->taxnum; my $exemptions = $self->{exemptions}->{$taxnum} ||= []; - my $taxable_cents = 0; + my $taxable_total = 0; my $tax_cents = 0; my $round_per_line_item = $conf->exists('tax-round_per_line_item'); @@ -302,15 +304,17 @@ sub taxline { }); push @tax_links, $location; - $taxable_cents += $taxable_charged; + $taxable_total += $taxable_charged; $tax_cents += $this_tax_cents; } #foreach $cust_bill_pkg - # calculate tax and rounding error for the whole group - my $extra_cents = sprintf('%.2f', $taxable_cents * $tax_object->tax / 100) - * 100 - $tax_cents; - # make sure we have an integer - $extra_cents = sprintf('%.0f', $extra_cents); + # calculate tax and rounding error for the whole group: total taxable + # amount times tax rate (as cents per dollar), minus the tax already + # charged + # and force 0.5 to round up + my $extra_cents = sprintf('%.0f', + ($taxable_total * $tax_object->tax) - $tax_cents + 0.00000001 + ); # if we're rounding per item, then ignore that and don't distribute any # extra cents. diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm index 248da3cae..28fbd591d 100644 --- a/FS/FS/TemplateItem_Mixin.pm +++ b/FS/FS/TemplateItem_Mixin.pm @@ -258,14 +258,25 @@ sub details { $sth->execute or die $sth->errstr; #avoid the fetchall_arrayref and loop for less memory usage? - - map { (defined($_->[0]) && $_->[0] eq 'C') - ? &{$format_sub}( $_->[1] ) - : &{$escape_function}( $_->[1] ); + # probably should use a cursor... + + my @return; + my $head = 1; + map { + my $row = $_; + if (defined($row->[0]) and $row->[0] eq 'C') { + if ($head) { + # first CSV row = the format header; localize it but not the others + $row->[1] = $self->mt($row->[1]); + $head = 0; } - @{ $sth->fetchall_arrayref }; + &{$format_sub}($row->[1]); + } else { + &{$escape_function}($row->[1]); + } + } @{ $sth->fetchall_arrayref }; - } + } #!$opt{format_function} } diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 1f67792df..c8ddffd79 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -147,6 +147,10 @@ sub print_latex { $template ||= $self->_agent_template if $self->can('_agent_template'); + #the new way + $self->set('mode', $params{mode}) + if $params{mode}; + my $pkey = $self->primary_key; my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX'; @@ -1669,6 +1673,13 @@ sub print_generic { } else { # this is where we actually create the invoice + if ( $params{no_addresses} ) { + delete $invoice_data{$_} foreach qw( + payname company address1 address2 city state zip country + ); + $invoice_data{returnaddress} = '~'; + } + warn "filling in template for invoice ". $self->invnum. "\n" if $DEBUG; warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n" @@ -2109,13 +2120,22 @@ sub generate_email { my $msg_template = FS::msg_template->by_key($msgnum) or die "${tc}email_pdf_msgnum $msgnum not found\n"; - my %prepared = $msg_template->prepare( + my $cust_msg = $msg_template->prepare( cust_main => $self->cust_main, - object => $self + object => $self, + msgtype => 'invoice', ); - @text = split(/(?=\n)/, $prepared{'text_body'}); - $html = $prepared{'html_body'}; + # XXX hack to make this work in the new cust_msg era; consider replacing + # with cust_bill_send_with_notice events. + my @parts = $cust_msg->parts; + foreach my $part (@parts) { # will only have two parts, normally + if ( $part->mime_type eq 'text/plain' ) { + @text = @{ $part->body }; + } elsif ( $part->mime_type eq 'text/html' ) { + $html = $part->bodyhandle->as_string; + } + } } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) { @@ -2351,6 +2371,106 @@ sub mimebuild_pdf { ); } +=item postal_mail_fsinc + +Sends this invoice to the Freeside Internet Services, Inc. print and mail +service. + +=cut + +use CAM::PDF; +use IO::Socket::SSL; +use LWP::UserAgent; +use HTTP::Request::Common qw( POST ); +use Cpanel::JSON::XS; +use MIME::Base64; +sub postal_mail_fsinc { + my ( $self, %opt ) = @_; + + my $url = 'https://ws.freeside.biz/print'; + + my $cust_main = $self->cust_main; + my $agentnum = $cust_main->agentnum; + my $bill_location = $cust_main->bill_location; + + die "Extra charges for international mailing; contact support\@freeside.biz to enable\n" + if $bill_location->country ne 'US'; + + my $conf = new FS::Conf; + + my @company_address = $conf->config('company_address', $agentnum); + my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip ); + if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) { + $company_address1 = $company_address[0]; + $company_address2 = $company_address[1]; + $company_city = $1; + $company_state = $2; + $company_zip = $3; + } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) { + $company_address1 = $company_address[0]; + $company_address2 = ''; + $company_city = $1; + $company_state = $2; + $company_zip = $3; + } else { + die "Unparsable company_address; contact support\@freeside.biz\n"; + } + $company_city =~ s/,$//; + + my $file = $self->print_pdf(%opt, 'no_addresses' => 1); + my $pages = CAM::PDF->new($file)->numPages; + + my $ua = LWP::UserAgent->new( + 'ssl_opts' => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + } + ); + my $response = $ua->request( POST $url, [ + 'support-key' => scalar($conf->config('support-key')), + 'file' => encode_base64($file), + 'pages' => $pages, + + #from: + 'company_name' => scalar( $conf->config('company_name', $agentnum) ), + 'company_address1' => $company_address1, + 'company_address2' => $company_address2, + 'company_city' => $company_city, + 'company_state' => $company_state, + 'company_zip' => $company_zip, + 'company_country' => 'US', + 'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)), + 'company_email' => scalar($conf->config('invoice_from', $agentnum)), + + #to: + 'name' => $cust_main->invoice_attn + || $cust_main->contact_firstlast, + 'company' => $cust_main->company, + 'address1' => $bill_location->address1, + 'address2' => $bill_location->address2, + 'city' => $bill_location->city, + 'state' => $bill_location->state, + 'zip' => $bill_location->zip, + 'country' => $bill_location->country, + ]); + + die "Print connection error: ". $response->message. "\n" + unless $response->is_success; + + local $@; + my $content = eval { decode_json($response->content) }; + die "Print JSON error : $@\n" if $@; + + die $content->{error}."\n" + if $content->{error}; + + #TODO: store this so we can query for a status later + warn "Invoice printed, ID ". $content->{id}. "\n"; + + $content->{id}; + +} + =item _items_sections OPTIONS Generate section information for all items appearing on this invoice. @@ -3066,7 +3186,9 @@ sub _items_cust_bill_pkg { # for location labels: use default location on the invoice date my $default_locationnum; - if ( $self->custnum ) { + if ( $conf->exists('invoice-all_pkg_addresses') ) { + $default_locationnum = 0; # treat them all as non-default + } elsif ( $self->custnum ) { my $h_cust_main; my @h_search = FS::h_cust_main->sql_h_search($self->_date); $h_cust_main = qsearchs({ @@ -3200,6 +3322,7 @@ sub _items_cust_bill_pkg { # append the word 'Setup' to the setup line if there's going to be # a recur line for the same package (i.e. not a one-time charge) + # XXX localization my $description = $desc; $description .= ' Setup' if $cust_bill_pkg->recur != 0 @@ -3220,8 +3343,11 @@ sub _items_cust_bill_pkg { # always pass the svc_label through to the template, even if # not displaying it as an ext_description my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date, undef, 'I'); - + $cust_pkg->h_labels_short($self->_date, + undef, + 'I', + $self->conf->{locale}, + ); $svc_label = $svc_labels[0]; unless ( $cust_pkg->part_pkg->hide_svc_detail @@ -3311,7 +3437,9 @@ sub _items_cust_bill_pkg { push @dates, undef if !$prev; my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short(@dates, 'I'); + $cust_pkg->h_labels_short(@dates, + 'I', + $self->conf->{locale}); $svc_label = $svc_labels[0]; # show service labels, unless... diff --git a/FS/FS/Test.pm b/FS/FS/Test.pm new file mode 100644 index 000000000..9c77417fe --- /dev/null +++ b/FS/FS/Test.pm @@ -0,0 +1,268 @@ +package FS::Test; + +use 5.006; +use strict; +use warnings FATAL => 'all'; + +use FS::UID qw(adminsuidsetup); +use FS::Record; +use URI; +use URI::Escape; +use Class::Accessor 'antlers'; +use Class::Load qw(load_class); +use File::Spec; +use HTML::Form; + +our $VERSION = '0.03'; + +=head1 NAME + +Freeside testing suite + +=head1 SYNOPSIS + + use Test::More 'tests' => 1; + use FS::Test; + my $FS = FS::Test->new; + $FS->post('/edit/cust_main.cgi', ... ); # form fields + ok( !$FS->error ); + +=head1 PROPERTIES + +=over 4 + +=item page + +The content of the most recent page fetched from the UI. + +=item redirect + +The redirect location (relative to the Freeside root) of the redirect +returned from the UI, if there was one. + +=head1 CLASS METHODS + +=item new OPTIONS + +Creates a test session. OPTIONS may contain: + +- user: the Freeside test username [test] +- base: the fake base URL for Mason to use [http://fake.freeside.biz] + +=cut + +has user => ( is => 'rw' ); +has base => ( is => 'ro' ); +has fs_interp => ( is => 'rw' ); +has path => ( is => 'rw' ); +has page => ( is => 'ro' ); +has error => ( is => 'rw' ); +has dbh => ( is => 'rw' ); +has redirect => ( is => 'rw' ); + +sub new { + my $class = shift; + my $self = { + user => 'test', + page => '', + error => '', + base => 'http://fake.freeside.biz', + @_ + }; + $self->{base} = URI->new($self->{base}); + bless $self; + + adminsuidsetup($self->user); + load_class('FS::Mason'); + $self->dbh( FS::UID::dbh() ); + + my ($fs_interp) = FS::Mason::mason_interps('standalone', + outbuf => \($self->{page}) + ); + $fs_interp->error_mode('fatal'); + $fs_interp->error_format('brief'); + + $self->fs_interp( $fs_interp ); + + RT::LoadConfig(); + RT::Init(); + + return $self; +} + +=back + +=head1 METHODS + +=over 4 + +=item post PATH, PARAMS + +=item post FORM + +Submits a request to PATH, through the Mason UI, with arguments in PARAMS. +This will be converted to a URL query string. Anything returned by the UI +will be in the C<page()> property. + +Alternatively, takes an L<HTML::Form> object (with fields filled in, via +the C<param()> method) and submits it. + +=cut + +sub post { + my $self = shift; + + # shut up, CGI + local $CGI::LIST_CONTEXT_WARN = 0; + + my ($path, $query); + if ( UNIVERSAL::isa($_[0], 'HTML::Form') ) { + my $form = shift; + my $request = $form->make_request; + $path = $request->uri->path; + $query = $request->content; + } else { + $path = shift; + my @params = @_; + if (scalar(@params) == 0) { + # possibly path?query syntax, or else no query string at all + ($path, $query) = split('\?', $path); + } elsif (scalar(@params) == 1) { + $query = uri_escape($params[0]); # keyword style + } else { + while (@params) { + $query .= uri_escape(shift @params) . '=' . + uri_escape(shift @params); + $query .= ';' if @params; + } + } + } + # remember which page this is + $self->path($path); + + local $FS::Mason::Request::FSURL = $self->base->as_string; + local $FS::Mason::Request::QUERY_STRING = $query; + # because we're going to construct an actual CGI object in here + local $ENV{SERVER_NAME} = $self->base->host; + local $ENV{SCRIPT_NAME} = $self->base->path . $path; + local $@ = ''; + my $mason_request = $self->fs_interp->make_request(comp => $path); + eval { + $mason_request->exec(); + }; + + if ( $@ ) { + if ( ref $@ eq 'HTML::Mason::Exception' ) { + $self->error($@->message); + } else { + $self->error($@); + } + } elsif ( $mason_request->notes('error') ) { + $self->error($mason_request->notes('error')); + } else { + $self->error(''); + } + + if ( my $loc = $mason_request->redirect ) { + my $base = $self->base->as_string; + $loc =~ s/^$base//; + $self->redirect($loc); + } else { + $self->redirect(''); + } + ''; # return error? HTTP status? something? +} + +=item proceed + +If the last request returned a redirect, follow it. + +=cut + +sub proceed { + my $self = shift; + if ($self->redirect) { + $self->post($self->redirect); + } + # else do nothing +} + +=item forms + +For the most recently returned page, returns a list of L<HTML::Form>s found. + +=cut + +sub forms { + my $self = shift; + my $formbase = $self->base->as_string . $self->path; + return HTML::Form->parse( $self->page, base => $formbase ); +} + +=item form NAME + +For the most recently returned page, returns an L<HTML::Form> object +representing the form named NAME. You can then call methods like +C<value(inputname, inputvalue)> to set the values of inputs on the form, +and then pass the form object to L</post> to submit it. + +=cut + +sub form { + my $self = shift; + my $name = shift; + my ($form) = grep { $_->attr('name') eq $name } $self->forms; + $form; +} + +=item qsearch ARGUMENTS + +Searches the database, like L<FS::Record::qsearch>. + +=item qsearchs ARGUMENTS + +Searches the database for a single record, like L<FS::Record::qsearchs>. + +=cut + +sub qsearch { + my $self = shift; + FS::Record::qsearch(@_); +} + +sub qsearchs { + my $self = shift; + FS::Record::qsearchs(@_); +} + +=item new_customer FIRSTNAME + +Returns an L<FS::cust_main> object full of default test data, ready to be inserted. +This doesn't insert the customer, because you might want to change some things first. +FIRSTNAME is recommended so you know which test the customer was used for. + +=cut + +sub new_customer { + my $self = shift; + my $first = shift || 'No Name'; + my $location = FS::cust_location->new({ + address1 => '123 Example Street', + city => 'Sacramento', + state => 'CA', + country => 'US', + zip => '94901', + }); + my $cust = FS::cust_main->new({ + agentnum => 1, + refnum => 1, + last => 'Customer', + first => $first, + invoice_email => 'newcustomer@fake.freeside.biz', + bill_location => $location, + ship_location => $location, + }); + $cust; +} + +1; # End of FS::Test diff --git a/FS/FS/TicketSystem/RT_Internal.pm b/FS/FS/TicketSystem/RT_Internal.pm index 1c4513e6d..99e7044fa 100644 --- a/FS/FS/TicketSystem/RT_Internal.pm +++ b/FS/FS/TicketSystem/RT_Internal.pm @@ -3,6 +3,7 @@ package FS::TicketSystem::RT_Internal; use strict; use vars qw( @ISA $DEBUG $me ); use Data::Dumper; +use Date::Format qw( time2str ); use MIME::Entity; use FS::UID qw(dbh); use FS::CGI qw(popurl); @@ -101,17 +102,43 @@ sub init { warn "$me init: complete" if $DEBUG; } -=item customer_tickets CUSTNUM [ LIMIT ] [ PRIORITYVALUE ] +=item customer_tickets CUSTNUM [ PARAMS ] Replacement for the one in RT_External so that we can access custom fields -properly. +properly. Accepts a hashref with the following parameters: + +number - custnum/svcnum + +limit + +priority + +status + +queueid + +resolved - only return tickets resolved after this timestamp =cut # create an RT::Tickets object for a specified custnum or svcnum sub _tickets_search { - my( $self, $type, $number, $limit, $priority, $status, $queueid ) = @_; + my $self = shift; + my $type = shift; + + my( $number, $limit, $priority, $status, $queueid, $opt ); + if ( ref($_[0]) eq 'HASH' ) { + $opt = shift; + $number = $$opt{'number'}; + $limit = $$opt{'limit'}; + $priority = $$opt{'priority'}; + $status = $$opt{'status'}; + $queueid = $$opt{'queueid'}; + } else { + ( $number, $limit, $priority, $status, $queueid ) = @_; + $opt = {}; + } $type =~ /^Customer|Service$/ or die "invalid type: $type"; $number =~ /^\d+$/ or die "invalid custnum/svcnum: $number"; @@ -161,6 +188,10 @@ sub _tickets_search { $rtql .= " AND Queue = $queueid " if $queueid; + if ($$opt{'resolved'}) { + $rtql .= " AND Resolved >= " . dbh->quote(time2str('%Y-%m-%d %H:%M:%S',$$opt{'resolved'})); + } + warn "$me _customer_tickets_search:\n$rtql\n" if $DEBUG; $Tickets->FromSQL($rtql); @@ -240,7 +271,8 @@ sub service_tickets { sub _ticket_info { # Takes an RT::Ticket; returns a hashref of the ticket's fields, including # custom fields. Also returns custom and selfservice priority values as - # _custom_priority and _selfservice_priority. + # _custom_priority and _selfservice_priority, and the IsUnreplied property + # as is_unreplied. my $t = shift; my $custom_priority = @@ -254,7 +286,10 @@ sub _ticket_info { } $ticket_info{'owner'} = $t->OwnerObj->Name; $ticket_info{'queue'} = $t->QueueObj->Name; + $ticket_info{'_cf_sort_order'} = {}; + my $cf_sort = 0; foreach my $CF ( @{ $t->CustomFields->ItemsArrayRef } ) { + $ticket_info{'_cf_sort_order'}{$CF->Name} = $cf_sort++; my $name = 'CF.{'.$CF->Name.'}'; $ticket_info{$name} = $t->CustomFieldValuesAsString($CF->Id); } @@ -265,6 +300,7 @@ sub _ticket_info { if ( $ss_priority ) { $ticket_info{'_selfservice_priority'} = $ticket_info{"CF.{$ss_priority}"}; } + $ticket_info{'is_unreplied'} = $t->IsUnreplied; my $svcnums = [ map { $_->Target =~ /cust_svc\/(\d+)/; $1 } @{ $t->Services->ItemsArrayRef } @@ -647,5 +683,49 @@ sub selfservice_priority { } } +=item custom_fields + +Returns a hash of custom field names and descriptions. + +Accepts the following options: + +lookuptype - limit results to this lookuptype + +valuetype - limit results to this valuetype + +Fields must be visible to CurrentUser. + +=cut + +sub custom_fields { + my $self = shift; + my %opt = @_; + my $lookuptype = $opt{lookuptype}; + my $valuetype = $opt{valuetype}; + + my $CurrentUser = RT::CurrentUser->new(); + $CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username); + die "RT not configured" unless $CurrentUser->id; + my $CFs = RT::CustomFields->new($CurrentUser); + + $CFs->UnLimit; + + $CFs->Limit(FIELD => 'LookupType', + OPERATOR => 'ENDSWITH', + VALUE => $lookuptype) + if $lookuptype; + + $CFs->Limit(FIELD => 'Type', + VALUE => $valuetype) + if $valuetype; + + my @fields; + while (my $CF = $CFs->Next) { + push @fields, $CF->Name, ($CF->Description || $CF->Name); + } + + return @fields; +} + 1; diff --git a/FS/FS/UI/Web.pm b/FS/FS/UI/Web.pm index 136c8e6af..04aeda103 100644 --- a/FS/FS/UI/Web.pm +++ b/FS/FS/UI/Web.pm @@ -15,7 +15,7 @@ use FS::cust_main; # are sql_balance and sql_date_balance in the right module? #@ISA = qw( FS::UI ); @ISA = qw( Exporter ); -@EXPORT_OK = qw( svc_url random_id ); +@EXPORT_OK = qw( get_page_pref set_page_pref svc_url random_id ); $DEBUG = 0; $me = '[FS::UID::Web]'; @@ -23,9 +23,79 @@ $me = '[FS::UID::Web]'; our $NO_RANDOM_IDS; ### +# user prefs +### + +=item get_page_pref NAME, TABLENUM + +Returns the user's page preference named NAME for the current page. If the +page is a view or edit page or otherwise shows a single record at a time, +it should use TABLENUM to link the preference to that record. + +=cut + +sub get_page_pref { + my ($prefname, $tablenum) = @_; + + my $m = $HTML::Mason::Commands::m + or die "can't get page pref when running outside the UI"; + # what's more useful: to tie prefs to the base_comp (usually where + # code is executing right now), or to the request_comp (approximately the + # one in the URL)? not sure. + $FS::CurrentUser::CurrentUser->get_page_pref( $m->request_comp->path, + $prefname, + $tablenum + ); +} + +=item set_page_pref NAME, TABLENUM, VALUE + +Sets the user's page preference named NAME for the current page. Use TABLENUM +as for get_page_pref. + +If VALUE is an empty string, the preference will be deleted (and +C<get_page_pref> will return an empty string). + + my $mypref = set_page_pref('mypref', '', 100); + +=cut + +sub set_page_pref { + my ($prefname, $tablenum, $prefvalue) = @_; + + my $m = $HTML::Mason::Commands::m + or die "can't set page pref when running outside the UI"; + $FS::CurrentUser::CurrentUser->set_page_pref( $m->request_comp->path, + $prefname, + $tablenum, + $prefvalue ); +} + +### # date parsing ### +=item parse_beginning_ending CGI [, PREFIX ] + +Parses a beginning/ending date range, as used on many reports. This function +recognizes two sets of CGI params: "begin" and "end", the integer timestamp +values, and "beginning" and "ending", the user-readable date fields. + +If "begin" contains an integer, that's passed through as the beginning date. +Otherwise, "beginning" is passed to L<DateTime::Format::Natural> and turned +into an integer. If this fails or it doesn't have a value, zero is used as the +beginning date. + +The same happens for "end" and "ending", except that if "ending" contains a +date without a time, it gets moved to the end of that day, and if there's no +value, the value returned is the highest unsigned 32-bit time value (some time +in 2037). + +PREFIX is optionally a string to prepend (with '_' as a delimiter) to the form +field names. + +=cut + use Date::Parse; sub parse_beginning_ending { my($cgi, $prefix) = @_; @@ -273,9 +343,11 @@ 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', ); $header2method{'Cust#'} = 'display_custnum' if $conf->exists('cust_main-default_agent_custid'); @@ -376,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 = (); @@ -385,6 +455,9 @@ sub cust_sql_fields { push @extra_fields, FS::cust_main->balance_sql . " AS current_balance"; } + push @extra_fields, 'part_referral_x.referral AS referral' + if grep { $_ eq 'referral' } @cust_fields; + map("cust_main.$_", @fields), @location_fields, @extra_fields; } @@ -449,6 +522,10 @@ sub join_cust_main { " ON (ship_location.locationnum = $location_table.$locationnum) "; } + if ( !@cust_fields or grep { $_ eq 'referral' } @cust_fields ) { + $sql .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) '; + } + $sql; } diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index bfb218f33..3faf47e24 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -165,19 +165,29 @@ If you need to continue using the old Form 477 report, turn on the $conf->delete('voip-cust_email_csv_cdr') ; } - if ( !$conf->config('password-generated-characters') ) { - my $pw_set = - 'abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789()#.,' ; - $conf->set('password-generated-characters', $pw_set); - } - if ($conf->exists('unsuspendauto') && !$conf->config('unsuspend_balance')) { $conf->set('unsuspend_balance','Zero'); $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')); + # if translate-auto-insert is enabled for a locale, ensure that invoice + # terms are in the msgcat (is there a better place for this?) + if (my $auto_locale = $conf->config('translate-auto-insert')) { + my $lh = FS::L10N->get_handle($auto_locale); + foreach (@FS::Conf::invoice_terms) { + $lh->maketext($_) if length($_); + } + } } sub upgrade_overlimit_groups { @@ -209,8 +219,9 @@ sub upgrade_overlimit_groups { sub upgrade_invoice_from { my ($conf, $agentnum, $agentonly) = @_; if ( - (!$conf->exists('invoice_from_name',$agentnum,$agentonly)) && - ($conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/) + ! $conf->exists('invoice_from_name',$agentnum,$agentonly) + && $conf->exists('invoice_from',$agentnum,$agentonly) + && $conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/ ) { my $realemail = $1; $realemail =~ s/^\s*//; # remove leading spaces @@ -341,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' => [], @@ -411,6 +425,9 @@ sub upgrade_data { 'cust_refund' => [], 'banned_pay' => [], + #paycardtype + 'cust_payby' => [], + #default namespace 'payment_gateway' => [], @@ -464,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; @@ -528,7 +549,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/access_right.pm b/FS/FS/access_right.pm index 57f67ded3..13a826f29 100644 --- a/FS/FS/access_right.pm +++ b/FS/FS/access_right.pm @@ -253,7 +253,10 @@ 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', ); # foreach my $old_acl ( keys %onetime ) { @@ -291,6 +294,33 @@ sub _upgrade_data { # class method } + # some false laziness with @onetime above, + # but for use when multiple old acls trigger a single new acl + # (keys/values reversed from @onetime, expects arrayref value) + my @onetime_bynew = ( + 'Customize billing during suspension' => [ 'Suspend customer package', 'Suspend customer package later' ], + ); + while ( @onetime_bynew ) { + my( $new_acl, $old_acl ) = splice(@onetime_bynew, 0, 2); + ( my $journal = 'ACL_'.lc($new_acl) ) =~ s/\W/_/g; + next if FS::upgrade_journal->is_done($journal); + # grant $new_acl to all groups who have one of @old_acl + for my $group (@all_groups) { + next unless grep { $group->access_right($_) } @$old_acl; + next if $group->access_right($new_acl); + my $access_right = FS::access_right->new( { + 'righttype' => 'FS::access_group', + 'rightobjnum' => $group->groupnum, + 'rightname' => $new_acl, + } ); + my $error = $access_right->insert; + die $error if $error; + } + + FS::upgrade_journal->set_done($journal); + + } + ### ACL_download_report_data if ( !FS::upgrade_journal->is_done('ACL_download_report_data') ) { diff --git a/FS/FS/access_user.pm b/FS/FS/access_user.pm index 3b36e46f0..a9fdf5b1e 100644 --- a/FS/FS/access_user.pm +++ b/FS/FS/access_user.pm @@ -742,6 +742,78 @@ sub locale { $self->{_locale} = $self->option('locale'); } +=item get_page_pref PATH, NAME, TABLENUM + +Returns the user's page preference named NAME for the page at PATH. If the +page is a view or edit page or otherwise shows a single record at a time, +it should use TABLENUM to tell which record the preference is for. + +=cut + +sub get_page_pref { + my $self = shift; + my ($path, $prefname, $tablenum) = @_; + $tablenum ||= ''; + + my $access_user_page_pref = qsearchs('access_user_page_pref', { + path => $path, + usernum => $self->usernum, + tablenum => $tablenum, + prefname => $prefname, + }); + $access_user_page_pref ? $access_user_page_pref->prefvalue : ''; +} + +=item set_page_pref PATH, NAME, TABLENUM, VALUE + +Sets the user's page preference named NAME for the page at PATH. Use TABLENUM +as for get_page_pref. + +=cut + +sub set_page_pref { + my $self = shift; + my ($path, $prefname, $tablenum, $prefvalue) = @_; + $tablenum ||= ''; + + my $error; + my $access_user_page_pref = qsearchs('access_user_page_pref', { + path => $path, + usernum => $self->usernum, + tablenum => $tablenum, + prefname => $prefname, + }); + if ( $access_user_page_pref ) { + if ( $prefvalue eq $access_user_page_pref->get('prefvalue') ) { + return ''; + } + if ( length($prefvalue) > 0 ) { + $access_user_page_pref->set('prefvalue', $prefvalue); + $error = $access_user_page_pref->replace; + $error .= " (updating $prefname)" if $error; + } else { + $error = $access_user_page_pref->delete; + $error .= " (removing $prefname)" if $error; + } + } else { + if ( length($prefvalue) > 0 ) { + $access_user_page_pref = FS::access_user_page_pref->new({ + path => $path, + usernum => $self->usernum, + tablenum => $tablenum, + prefname => $prefname, + prefvalue => $prefvalue, + }); + $error = $access_user_page_pref->insert; + $error .= " (creating $prefname)" if $error; + } else { + return ''; + } + } + + return $error; +} + =back =head1 BUGS diff --git a/FS/FS/access_user_log.pm b/FS/FS/access_user_log.pm index 15437b772..563f3cef0 100644 --- a/FS/FS/access_user_log.pm +++ b/FS/FS/access_user_log.pm @@ -2,6 +2,7 @@ package FS::access_user_log; use base qw( FS::Record ); use strict; +use FS::UID qw( dbh ); #use FS::Record qw( qsearch qsearchs ); use FS::CurrentUser; @@ -85,6 +86,11 @@ sub insert_new_path { 'render_seconds' => $render_seconds, } ); + #so we can still log pages after a transaction-aborting SQL error (and then + # show the # error page) + local($FS::UID::dbh) = dbh->clone; + #if current transaction is aborted (if we had a way to check for it) + my $error = $self->insert; die $error if $error; diff --git a/FS/FS/access_user_page_pref.pm b/FS/FS/access_user_page_pref.pm new file mode 100644 index 000000000..890d29f17 --- /dev/null +++ b/FS/FS/access_user_page_pref.pm @@ -0,0 +1,128 @@ +package FS::access_user_page_pref; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +sub table { 'access_user_page_pref'; } + +=head1 NAME + +FS::access_user_page_pref - Object methods for access_user_page_pref records + +=head1 SYNOPSIS + + use FS::access_user_page_pref; + + $record = new FS::access_user_page_pref \%hash; + $record = new FS::access_user_page_pref { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::access_user_page_pref object represents a per-page user interface +preference. FS::access_user_page_pref inherits from FS::Record. The +following fields are currently supported: + +=over 4 + +=item prefnum + +primary key + +=item usernum + +The user who has this preference, a L<FS::access_user> foreign key. + +=item path + +The path of the page where the preference is set, relative to the Mason +document root. + +=item tablenum + +For view and edit pages (which show one record at a time), the record primary +key that the preference applies to. + +=item _date + +The date the preference was created. + +=item prefname + +The name of the preference, as defined by the page. + +=item prefvalue + +The value (a free-text field). + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new preference. To add the preference to the database, see +L<"insert">. + +=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 preference. 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('_date', time) unless $self->get('_date'); + + my $error = + $self->ut_numbern('prefnum') + || $self->ut_number('usernum') + || $self->ut_foreign_key('usernum', 'access_user', 'usernum') + || $self->ut_text('path') + || $self->ut_numbern('tablenum') + || $self->ut_numbern('_date') + || $self->ut_text('prefname') + || $self->ut_text('prefvalue') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 SEE ALSO + +L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm index 4ad878d43..fc234334d 100644 --- a/FS/FS/agent.pm +++ b/FS/FS/agent.pm @@ -3,7 +3,7 @@ use base qw( FS::Commission_Mixin FS::m2m_Common FS::m2name_Common FS::Record ); use strict; use vars qw( @ISA ); -use Business::CreditCard 0.28; +use Business::CreditCard 0.35; use FS::Record qw( dbh qsearch qsearchs ); use FS::cust_main; use FS::cust_pkg; diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm index 8ccf7af63..155090dbd 100644 --- a/FS/FS/cdr.pm +++ b/FS/FS/cdr.pm @@ -3,6 +3,7 @@ package FS::cdr; use strict; use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf $cdr_prerate %cdr_prerate_cdrtypenums + $use_lrn $support_key ); use Exporter; use List::Util qw(first min); @@ -24,6 +25,11 @@ use FS::rate; use FS::rate_prefix; use FS::rate_detail; +# LRN lookup +use LWP::UserAgent; +use HTTP::Request::Common qw(POST); +use Cpanel::JSON::XS qw(decode_json); + @ISA = qw(FS::Record); @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker ); @@ -39,6 +45,10 @@ FS::UID->install_callback( sub { @cdr_prerate_cdrtypenums = $conf->config('cdr-prerate-cdrtypenums') if $cdr_prerate; %cdr_prerate_cdrtypenums = map { $_=>1 } @cdr_prerate_cdrtypenums; + + $support_key = $conf->config('support-key'); + $use_lrn = $conf->exists('cdr-lrn_lookup'); + }); =head1 NAME @@ -215,6 +225,8 @@ sub table_info { 'upstream_price' => 'Upstream price', #'upstream_rateplanid' => '', #'ratedetailnum' => '', + 'src_lrn' => 'Source LRN', + 'dst_lrn' => 'Dest. LRN', 'rated_price' => 'Rated price', 'rated_cost' => 'Rated cost', #'distance' => '', @@ -495,8 +507,9 @@ sub set_status_and_rated_price { rated_price => $rated_price, status => $status, }); - $term->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds}); - $term->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes}); + foreach (qw(rated_seconds rated_minutes rated_granularity)) { + $term->set($_, $opt{$_}) if exists($opt{$_}); + } $term->svcnum($svcnum) if $svcnum; return $term->insert; @@ -546,6 +559,9 @@ sub parse_number { my $field = $options{column} || 'dst'; my $intl = $options{international_prefix} || '011'; + # Still, don't break anyone's CDR rating if they have an empty string in + # there. Require an explicit statement that there's no prefix. + $intl = '' if lc($intl) eq 'none'; my $countrycode = ''; my $number = $self->$field(); @@ -683,9 +699,6 @@ sub rate_prefix { } } - - - ### # look up rate details based on called station id # (or calling station id for toll free calls) @@ -719,13 +732,32 @@ sub rate_prefix { domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'), ); + my $ratename = ''; + my $intrastate_ratenum = $part_pkg->option_cacheable('intrastate_ratenum'); + + if ( $use_lrn and $countrycode eq '1' ) { + + # then ask about the number + foreach my $field ('src', 'dst') { + + $self->get_lrn($field); + if ( $field eq $column ) { + # then we are rating on this number + $number = $self->get($field.'_lrn'); + $number =~ s/^1//; + # is this ever meaningful? can the LRN be outside NANP space? + } + + } # foreach $field + + } + warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG; my $pretty_dst = "+$countrycode $number"; #asterisks here causes inserting the detail to barf, so: $pretty_dst =~ s/\*//g; - my $ratename = ''; - my $intrastate_ratenum = $part_pkg->option_cacheable('intrastate_ratenum'); + # should check $countrycode eq '1' here? if ( $intrastate_ratenum && !$self->is_tollfree ) { $ratename = 'Interstate'; #until proven otherwise # this is relatively easy only because: @@ -734,8 +766,10 @@ sub rate_prefix { # -disregard private or unknown numbers # -there is exactly one record in rate_prefix for a given NPANXX # -default to interstate if we can't find one or both of the prefixes + my $dst_col = $use_lrn ? 'dst_lrn' : 'dst'; + my $src_col = $use_lrn ? 'src_lrn' : 'src'; my (undef, $dstprefix) = $self->parse_number( - column => 'dst', + column => $dst_col, international_prefix => $part_pkg->option_cacheable('international_prefix'), domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'), ); @@ -744,7 +778,7 @@ sub rate_prefix { 'npa' => $1, }) || ''; my (undef, $srcprefix) = $self->parse_number( - column => 'src', + column => $src_col, international_prefix => $part_pkg->option_cacheable('international_prefix'), domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'), ); @@ -924,8 +958,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; @@ -1259,6 +1295,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', @@ -1267,6 +1307,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 = (); @@ -1450,6 +1494,38 @@ sub downstream_csv { } +sub get_lrn { + my $self = shift; + my $field = shift; + + my $ua = LWP::UserAgent->new; + my $url = 'https://ws.freeside.biz/get_lrn'; + + my %content = ( 'support-key' => $support_key, + 'tn' => $self->get($field), + ); + my $response = $ua->request( POST $url, \%content ); + + die "LRN service error: ". $response->message. "\n" + unless $response->is_success; + + local $@; + my $data = eval { decode_json($response->content) }; + die "LRN service JSON error : $@\n" if $@; + + if ($data->{error}) { + die "acctid ".$self->acctid." $field LRN lookup failed:\n$data->{error}"; + # for testing; later we should respect ignore_unrateable + } elsif ($data->{lrn}) { + # normal case + $self->set($field.'_lrn', $data->{lrn}); + } else { + die "acctid ".$self->acctid." $field LRN lookup returned no number.\n"; + } + + return $data; # in case it's interesting somehow +} + =back =head1 CLASS METHODS diff --git a/FS/FS/cdr/conexiant.pm b/FS/FS/cdr/conexiant.pm new file mode 100644 index 000000000..4ee3f149d --- /dev/null +++ b/FS/FS/cdr/conexiant.pm @@ -0,0 +1,44 @@ +package FS::cdr::conexiant; +use base qw( FS::cdr ); + +use strict; +use vars qw( %info ); +use FS::Record qw( qsearchs ); +use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker ); + +%info = ( + 'name' => 'Conexiant', + 'weight' => 600, + 'header' => 1, + 'type' => 'csv', + 'import_fields' => [ + skip(3), #LookupError,Direction,LegType + sub { #CallId + my($cdr,$value,$conf,$param) = @_; + #filter out already-imported cdrs here + if (qsearchs('cdr',{'uniqueid' => $value})) { + $param->{'skiprow'} = 1; + $param->{'unique_skip'} = 1; #tell batch_import why we're skipping + } else { + $cdr->uniqueid($value); + } + }, + 'upstream_rateplanid', #ClientRateSheetId + skip(1), #ClientRouteId + 'src', #SourceNumber + skip(1), #RawNumber + 'dst', #DestNumber + skip(1), #DestLRN + _cdr_date_parser_maker('startdate'), #CreatedOn + _cdr_date_parser_maker('answerdate'), #AnsweredOn + _cdr_date_parser_maker('enddate'), #HangupOn + skip(4), #CallCause,SipCode,Price,USFCharge + 'upstream_price', #TotalPrice + _cdr_min_parser_maker('billsec'), #PriceDurationMins + skip(2), #SipEndpointId, SipEndpointName + ], +); + +sub skip { map {''} (1..$_[0]) } + +1; diff --git a/FS/FS/cdr/vss.pm b/FS/FS/cdr/vss.pm deleted file mode 100644 index a550303df..000000000 --- a/FS/FS/cdr/vss.pm +++ /dev/null @@ -1,33 +0,0 @@ -package FS::cdr::vss; - -use strict; -use vars qw( @ISA %info $tmp_mon $tmp_mday $tmp_year ); -use Time::Local; -use FS::cdr qw(_cdr_date_parser_maker); - -@ISA = qw(FS::cdr); - -%info = ( - 'name' => 'VSS', - 'weight' => 120, - 'header' => 1, - 'import_fields' => [ - - skip(1), # i_customer - 'accountcode', # account_id - 'src', # caller - 'dst', # called - skip(2), # reason - # call id - _cdr_date_parser_maker('startdate'), # time - 'billsec', # duration - skip(3), # ringtime - # status - # resller_charge - 'upstream_price',# customer_charge - ], -); - -sub skip { map {''} (1..$_[0]) } - -1; diff --git a/FS/FS/cdr/vvs.pm b/FS/FS/cdr/vvs.pm index 63a647ee8..db7e72ac6 100644 --- a/FS/FS/cdr/vvs.pm +++ b/FS/FS/cdr/vvs.pm @@ -18,12 +18,11 @@ use FS::cdr qw(_cdr_date_parser_maker); 'src', # caller 'dst', # called skip(2), # reason - # call id + # call id _cdr_date_parser_maker('startdate'), # time 'billsec', # duration - skip(3), # ringtime - # status - # resller_charge + skip(2), # ringtime + # reseller_charge 'upstream_price',# customer_charge ], ); diff --git a/FS/FS/cdr_termination.pm b/FS/FS/cdr_termination.pm index 0209f0d0c..3c1f453d8 100644 --- a/FS/FS/cdr_termination.pm +++ b/FS/FS/cdr_termination.pm @@ -47,6 +47,12 @@ termpart rated_price +=item rated_seconds + +=item rated_minutes + +=item rated_granularity + =item status status @@ -120,6 +126,9 @@ sub check { #|| $self->ut_foreign_key('termpart', 'part_termination', 'termpart') || $self->ut_number('termpart') || $self->ut_floatn('rated_price') + || $self->ut_numbern('rated_seconds') + || $self->ut_floatn('rated_minutes') + || $self->ut_numbern('rated_granularity') || $self->ut_enum('status', [ '', 'processing-tiered', 'done' ] ) # , 'skipped' ] ) ; return $error if $error; 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/contact.pm b/FS/FS/contact.pm index 592c7199f..fd3e9d770 100644 --- a/FS/FS/contact.pm +++ b/FS/FS/contact.pm @@ -112,10 +112,10 @@ sub table { 'contact'; } Adds this record to the database. If there is an error, returns the error, otherwise returns false. -If the object has an C<emailaddress> field, L<FS::contact_email> records will -be created for each (comma-separated) email address in that field. If any of -these coincide with an existing email address, this contact will be merged with -the contact with that address. +If the object has an C<emailaddress> field, L<FS::contact_email> records +will be created for each (comma-separated) email address in that field. If +any of these coincide with an existing email address, this contact will be +merged with the contact with that address. Then, if the object has any fields named C<phonetypenumN> an L<FS::contact_phone> record will be created for each of them. Those fields @@ -206,6 +206,10 @@ sub insert { } my $cust_contact = ''; + # if $self->custnum was set, then the customer-specific properties + # (custnum, classnum, invoice_dest, selfservice_access, comment) are in + # pseudo-fields, and are now in %link_hash. otherwise, ignore all those + # fields. if ( $custnum ) { my %hash = ( 'contactnum' => $self->contactnum, 'custnum' => $custnum, @@ -337,6 +341,8 @@ sub delete { } } + # if $self->custnum was set, then we're removing the contact from this + # customer. if ( $self->custnum ) { my $cust_contact = qsearchs('cust_contact', { 'contactnum' => $self->contactnum, @@ -438,6 +444,10 @@ sub replace { } my $cust_contact = ''; + # if $self->custnum was set, then the customer-specific properties + # (custnum, classnum, invoice_dest, selfservice_access, comment) are in + # pseudo-fields, and are now in %link_hash. otherwise, ignore all those + # fields. if ( $custnum ) { my %hash = ( 'contactnum' => $self->contactnum, 'custnum' => $custnum, @@ -743,9 +753,9 @@ sub firstlast { =item by_selfservice_email EMAILADDRESS -Alternate search constructor (class method). Given an email address, -returns the contact for that address, or the empty string if no contact -has that email address. +Alternate search constructor (class method). Given an email address, returns +the contact for that address. If that contact doesn't have selfservice access, +or there isn't one, returns the empty string. =cut @@ -756,7 +766,8 @@ sub by_selfservice_email { 'table' => 'contact_email', 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ', 'hashref' => { 'emailaddress' => $email, }, - 'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )", + 'extra_sql' => " AND ( contact.disabled IS NULL ) ". + " AND ( contact.selfservice_access = 'Y' )", }) or return ''; $contact_email->contact; @@ -877,9 +888,9 @@ sub send_reset_email { my $agentnum = $cust_main ? $cust_main->agentnum : ''; my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum); #die "selfservice-password_reset_msgnum unset" unless $msgnum; - return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum; + return "selfservice-password_reset_msgnum unset" unless $msgnum; my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } ); - return { 'error' => "selfservice-password_reset_msgnum cannot be loaded" } unless $msg_template; + return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template; my %msg_template = ( 'to' => join(',', map $_->emailaddress, @contact_email ), 'cust_main' => $cust_main, @@ -891,7 +902,7 @@ sub send_reset_email { my $cust_msg = $msg_template->prepare( %msg_template ); my $error = $cust_msg->insert; - return { 'error' => $error } if $error; + return $error if $error; my $queue = new FS::queue { 'job' => 'FS::cust_msg::process_send', 'custnum' => $cust_main ? $cust_main->custnum : '', @@ -942,6 +953,20 @@ use FS::upgrade_journal; sub _upgrade_data { #class method my ($class, %opts) = @_; + # before anything else, migrate contact.custnum to cust_contact records + unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) { + + local($skip_fuzzyfiles) = 1; + + foreach my $contact (qsearch('contact', {})) { + my $error = $contact->replace; + die $error if $error; + } + + FS::upgrade_journal->set_done('contact_invoice_dest'); + } + + # always migrate cust_main_invoice records over local $FS::cust_main::import = 1; # override require_phone and such my $search = FS::Cursor->new('cust_main_invoice', {}); @@ -974,18 +999,6 @@ sub _upgrade_data { #class method } } - unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) { - - local($skip_fuzzyfiles) = 1; - - foreach my $contact (qsearch('contact', {})) { - my $error = $contact->replace; - die $error if $error; - } - - FS::upgrade_journal->set_done('contact_invoice_dest'); - } - } =back diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 3ee6d4736..79dbbba67 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1115,9 +1115,8 @@ sub queueable_email { my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } ) or die "invalid invoice number: " . $opt{invnum}; - if ( $opt{mode} ) { - $self->set('mode', $opt{mode}); - } + $self->set('mode', $opt{mode}) + if $opt{mode}; my %args = map {$_ => $opt{$_}} grep { $opt{$_} } @@ -1258,6 +1257,10 @@ sub batch_invoice { batchnum => $bill_batch->batchnum, invnum => $self->invnum, }); + if ( $self->mode ) { + $opt->{mode} ||= $self->mode; + $opt->{mode} = $opt->{mode}->modenum if ref $opt->{mode}; + } return $cust_bill_batch->insert($opt); } diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index e7860e071..a1762e471 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -832,34 +832,53 @@ sub _item_discount { my $self = shift; my %options = @_; + my $d; # this will be returned. + my @pkg_discounts = $self->pkg_discount; - return if @pkg_discounts == 0; - # special case: if there are old "discount details" on this line item, don't - # show discount line items - if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) { - return; - } - - my @ext; - my $d = { - _is_discount => 1, - description => $self->mt('Discount'), - setup_amount => 0, - recur_amount => 0, - ext_description => \@ext, - pkgpart => $self->pkgpart, - feepart => $self->feepart, - # maybe should show quantity/unit discount? - }; - foreach my $pkg_discount (@pkg_discounts) { - push @ext, $pkg_discount->description; - my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur; - $d->{$setuprecur.'_amount'} -= $pkg_discount->amount; - } - $d->{setup_amount} *= $self->quantity || 1; # ?? - $d->{recur_amount} *= $self->quantity || 1; # ?? - - return $d; + if (@pkg_discounts) { + # special case: if there are old "discount details" on this line item, + # don't show discount line items + if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) { + return; + } + + my @ext; + $d = { + _is_discount => 1, + description => $self->mt('Discount'), + setup_amount => 0, + recur_amount => 0, + ext_description => \@ext, + pkgpart => $self->pkgpart, + feepart => $self->feepart, + # maybe should show quantity/unit discount? + }; + foreach my $pkg_discount (@pkg_discounts) { + push @ext, $pkg_discount->description; + my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur; + $d->{$setuprecur.'_amount'} -= $pkg_discount->amount; + } + } + + # show introductory rate as a pseudo-discount + if (!$d) { # this will conflict with showing real discounts + my $part_pkg = $self->part_pkg; + if ( $part_pkg and $part_pkg->option('show_as_discount') ) { + my $cust_pkg = $self->cust_pkg; + my $intro_end = $part_pkg->intro_end($cust_pkg); + my $_date = $self->cust_bill->_date; + if ( $intro_end > $_date ) { + $d = $part_pkg->item_discount($cust_pkg); + } + } + } + + if ( $d ) { + $d->{setup_amount} *= $self->quantity || 1; # ?? + $d->{recur_amount} *= $self->quantity || 1; # ?? + } + + $d; } =item set_display OPTION => VALUE ... @@ -1101,17 +1120,34 @@ sub cust_bill_pkg_tax_Xlocation { =item recur_show_zero -=cut +Whether to show a zero recurring amount. This is true if the package or its +definition has the recur_show_zero flag, and the recurring fee is actually +zero for this period. -sub recur_show_zero { shift->_X_show_zero('recur'); } -sub setup_show_zero { shift->_X_show_zero('setup'); } +=cut -sub _X_show_zero { +sub recur_show_zero { my( $self, $what ) = @_; - return 0 unless $self->$what() == 0 && $self->pkgnum; + return 0 unless $self->get('recur') == 0 && $self->pkgnum; + + $self->cust_pkg->_X_show_zero('recur'); +} - $self->cust_pkg->_X_show_zero($what); +=item setup_show_zero + +Whether to show a zero setup charge. This requires the package or its +definition to have the setup_show_zero flag, but it also returns false if +the package's setup date is before this line item's start date. + +=cut + +sub setup_show_zero { + my $self = shift; + return 0 unless $self->get('setup') == 0 && $self->pkgnum; + my $cust_pkg = $self->cust_pkg; + return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 ); + return $cust_pkg->_X_show_zero('setup'); } =item credited [ BEFORE, AFTER, OPTIONS ] 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 094437e22..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; @@ -1087,12 +1088,47 @@ use FS::cust_credit_bill; sub process_batch_import { my $job = shift; + # some false laziness with FS::cust_pay::process_batch_import + my $hashcb = sub { + my %hash = @_; + my $custnum = $hash{'custnum'}; + my $agent_custid = $hash{'agent_custid'}; + # translate agent_custid into regular custnum + if ($custnum && $agent_custid) { + die "can't specify both custnum and agent_custid\n"; + } elsif ($agent_custid) { + # here is the agent virtualization + my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql; + my %search; + $search{'agent_custid'} = $agent_custid + if $agent_custid; + $search{'custnum'} = $custnum + if $custnum; + my $cust_main = qsearchs({ + 'table' => 'cust_main', + 'hashref' => \%search, + 'extra_sql' => $extra_sql, + }); + die "can't find customer with" . + ($custnum ? " custnum $custnum" : '') . + ($agent_custid ? " agent_custid $agent_custid" : '') . "\n" + unless $cust_main; + die "mismatched customer number\n" + if $custnum && ($custnum ne $cust_main->custnum); + $custnum = $cust_main->custnum; + } + $hash{'custnum'} = $custnum; + delete($hash{'agent_custid'}); + return %hash; + }; + my $opt = { 'table' => 'cust_credit', 'params' => [ '_date', 'credbatch' ], 'formats' => { 'simple' => - [ 'custnum', 'amount', 'reasonnum', 'invnum' ], + [ 'custnum', 'amount', 'reasonnum', 'invnum', 'agent_custid' ], }, 'default_csv' => 1, + 'format_hash_callbacks' => { 'simple' => $hashcb }, 'postinsert_callback' => sub { my $cust_credit = shift; #my ($cust_credit, $param ) = @_; diff --git a/FS/FS/cust_event.pm b/FS/FS/cust_event.pm index 1d8af1e6e..094c4fa8b 100644 --- a/FS/FS/cust_event.pm +++ b/FS/FS/cust_event.pm @@ -12,7 +12,7 @@ use FS::cust_bill; use FS::cust_pay; use FS::svc_acct; -$DEBUG = 0; +$DEBUG = 1; $me = '[FS::cust_event]'; =head1 NAME @@ -54,6 +54,13 @@ L<Time::Local> and L<Date::Parse> for conversion functions. =item statustext - additional status detail (i.e. error or progress message) +=item no_action - 'Y' if the event action wasn't performed. Some actions +contain an internal check to see if the action is going to be impossible (for +example, emailing a notice to a customer who has no email address), and if so, +won't attempt the action. It shouldn't be reported as a failure because +there's no need to retry it. However, the action should set no_action = 'Y' +so that there's a record. + =back =head1 METHODS @@ -141,6 +148,7 @@ sub check { || $self->ut_number('_date') || $self->ut_enum('status', [qw( new locked done failed initial)]) || $self->ut_anything('statustext') + || $self->ut_flag('no_action') ; return $error if $error; @@ -237,7 +245,13 @@ sub do_event { $statustext = "Error running ". $part_event->action. " action: $@"; } elsif ( $error ) { $status = 'done'; - $statustext = $error; + if ( $error eq 'N/A' ) { + # archaic way to indicate no-op completion of spool_csv (and maybe + # other events)? + $self->no_action('Y'); + } else { + $statustext = $error; + } } else { $status = 'done'; } @@ -372,11 +386,65 @@ sub search_sql_where { push @search, "cust_event._date <= $1"; } - if ( $param->{'failed'} ) { - push @search, "statustext != ''", - "statustext IS NOT NULL", - "statustext != 'N/A'"; - } + #if ( $param->{'failed'} ) { + # push @search, "statustext != ''", + # "statustext IS NOT NULL", + # "statustext != 'N/A'"; + #} + # huh? + + my @event_status = ref($param->{'event_status'}) + ? @{ $param->{'event_status'} } + : split(',', $param->{'event_status'}); + if ( @event_status ) { + my @status; + + my ($done_Y, $done_N, $done_S); + # done_Y: action was taken + # done_N: action was not taken + # done_S: status message returned + foreach (@event_status) { + if ($_ eq 'done_Y') { + $done_Y = 1; + } elsif ( $_ eq 'done_N' ) { + $done_N = 1; + } elsif ( $_ eq 'done_S' ) { + $done_S = 1; + } else { + push @status, $_; + } + } + if ( $done_Y or $done_N or $done_S ) { + push @status, 'done'; + } + if ( @status ) { + push @search, "cust_event.status IN(" . + join(',', map "'$_'", @status) . + ')'; + } + + # done_S status should include only those where statustext is not null, + # and done_Y should include only those where it is. + if ( $done_Y and $done_N and $done_S ) { + # then not necessary + } else { + my @done_status; + if ( $done_Y ) { + push @done_status, "(cust_event.no_action IS NULL AND cust_event.statustext IS NULL)"; + } + if ( $done_N ) { + push @done_status, "(cust_event.no_action = 'Y')"; + } + if ( $done_S ) { + push @done_status, "(cust_event.no_action IS NULL AND cust_event.statustext IS NOT NULL)"; + } + push @search, join(' OR ', @done_status) if @done_status; + } + + } # event_status + + # always hide initialization + push @search, 'cust_event.status != \'initial\''; if ( $param->{'custnum'} =~ /^(\d+)$/ ) { push @search, "cust_main.custnum = '$1'"; diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm index 2b8a5c88d..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 @@ -249,20 +253,22 @@ sub insert { # cust_location exports #my $export_args = $options{'export_args'} || []; - my @part_export = - map qsearch( 'part_export', {exportnum=>$_} ), - $conf->config('cust_location-exports'); #, $agentnum - - foreach my $part_export ( @part_export ) { - my $error = $part_export->export_insert($self); #, @$export_args); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "exporting to ". $part_export->exporttype. - " (transaction rolled back): $error"; + # don't export custnum_pending cases, let follow-up replace handle that + if ($self->custnum || $self->prospectnum) { + my @part_export = + map qsearch( 'part_export', {exportnum=>$_} ), + $conf->config('cust_location-exports'); #, $agentnum + + foreach my $part_export ( @part_export ) { + my $error = $part_export->export_insert($self); #, @$export_args); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "exporting to ". $part_export->exporttype. + " (transaction rolled back): $error"; + } } } - $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; } @@ -290,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 $_"; @@ -311,20 +317,22 @@ sub replace { # cust_location exports #my $export_args = $options{'export_args'} || []; - my @part_export = - map qsearch( 'part_export', {exportnum=>$_} ), - $conf->config('cust_location-exports'); #, $agentnum - - foreach my $part_export ( @part_export ) { - my $error = $part_export->export_replace($self, $old); #, @$export_args); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "exporting to ". $part_export->exporttype. - " (transaction rolled back): $error"; + # don't export custnum_pending cases, let follow-up replace handle that + if ($self->custnum || $self->prospectnum) { + my @part_export = + map qsearch( 'part_export', {exportnum=>$_} ), + $conf->config('cust_location-exports'); #, $agentnum + + foreach my $part_export ( @part_export ) { + my $error = $part_export->export_replace($self, $old); #, @$export_args); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "exporting to ". $part_export->exporttype. + " (transaction rolled back): $error"; + } } } - $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; } @@ -343,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') @@ -377,10 +389,12 @@ sub check { $self->censustract("$1.$2"); } - if ( $conf->exists('cust_main-require_address2') and - !$self->ship_address2 =~ /\S/ ) { - return "Unit # is required"; - } + #yikes... this is ancient, pre-dates cust_location and will be harder to + # implement now... how do we know this location is a service location from + # here and not a billing? we can't just check locationnums, we might be new :/ + return "Unit # is required" + if $conf->exists('cust_main-require_address2') + && ! $self->address2 =~ /\S/; # tricky...we have to allow for the customer to not be inserted yet return "No prospect or customer!" unless $self->prospectnum @@ -716,15 +730,18 @@ sub label_prefix { } elsif ( $label_prefix eq '_location' && $self->locationname ) { $prefix = $self->locationname; - } elsif ( ( $opt{'cust_main'} || $self->custnum ) - && $self->locationnum == $cust_or_prospect->ship_locationnum ) { - $prefix = 'Default service location'; + #} elsif ( ( $opt{'cust_main'} || $self->custnum ) + # && $self->locationnum == $cust_or_prospect->ship_locationnum ) { + # $prefix = 'Default service location'; + #} + } else { + $prefix = ''; } $prefix; } -=item county_state_county +=item county_state_country Returns a string consisting of just the county, state and country. @@ -878,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.pm b/FS/FS/cust_main.pm index 4e305fc2b..2af6a1f01 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -29,6 +29,7 @@ use Date::Format; use File::Temp; #qw( tempfile ); use Business::CreditCard 0.28; use List::Util qw(min); +use Try::Tiny; use FS::UID qw( dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef regexp_sql ); use FS::Cursor; @@ -76,6 +77,7 @@ use FS::upgrade_journal; use FS::sales; use FS::cust_payby; use FS::contact; +use FS::reason; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations @@ -532,6 +534,7 @@ sub insert { foreach my $prospect_contact ( $prospect_main->prospect_contact ) { my $cust_contact = new FS::cust_contact { 'custnum' => $self->custnum, + 'invoice_dest' => 'Y', # invoice_dest currently not set for prospect contacts map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment ) }; my $error = $cust_contact->insert @@ -554,7 +557,10 @@ sub insert { return $error; } } - + # since we set invoice_dest on all migrated prospect contacts (for now), + # don't process invoicing_list. + delete $options{'invoicing_list'}; + $invoicing_list = undef; } warn " setting contacts\n" @@ -578,8 +584,7 @@ sub insert { custnum => $self->custnum, }); $cust_contact->set('invoice_dest', 'Y'); - my $error = $cust_contact->contactnum ? - $cust_contact->replace : $cust_contact->insert; + my $error = $cust_contact->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; return "$error (linking to email address $dest)"; @@ -1772,7 +1777,7 @@ sub check { || $self->ut_flag('message_noemail') || $self->ut_enum('locale', [ '', FS::Locales->locales ]) || $self->ut_currencyn('currency') - || $self->ut_alphan('po_number') + || $self->ut_textn('po_number') || $self->ut_enum('complimentary', [ '', 'Y' ]) || $self->ut_flag('invoice_ship_address') || $self->ut_flag('invoice_dest') @@ -1991,7 +1996,9 @@ sub cust_payby { 'hashref' => { 'custnum' => $self->custnum }, 'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC", }; - $search->{'extra_sql'} = ' AND payby IN ( ' . join(',', map { dbh->quote($_) } @payby) . ' ) ' + $search->{'extra_sql'} = ' AND payby IN ( '. + join(',', map dbh->quote($_), @payby). + ' ) ' if @payby; qsearch($search); @@ -2121,33 +2128,63 @@ sub suspend_unless_pkgpart { =item cancel [ OPTION => VALUE ... ] Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer. +The cancellation time will be now. -Available options are: +=back + +Always returns a list: an empty list on success or a list of errors. + +=cut + +sub cancel { + my $self = shift; + my %opt = @_; + warn "$me cancel called on customer ". $self->custnum. " with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" + if $DEBUG; + my @pkgs = $self->ncancelled_pkgs; + + $self->cancel_pkgs( %opt, 'cust_pkg' => \@pkgs ); +} + +=item cancel_pkgs OPTIONS + +Cancels a specified list of packages. OPTIONS can include: =over 4 +=item cust_pkg - an arrayref of the packages. Required. + +=item time - the cancellation time, used to calculate final bills and +unused-time credits if any. Will be passed through to the bill() and +FS::cust_pkg::cancel() methods. + =item quiet - can be set true to supress email cancellation notices. -=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason. +=item reason - can be set to a cancellation reason (see L<FS:reason>), either a +reasonnum of an existing reason, or passing a hashref will create a new reason. +The hashref should have the following keys: +typenum - Reason type (see L<FS::reason_type>) +reason - Text of the new reason. + +=item cust_pkg_reason - can be an arrayref of L<FS::cust_pkg_reason> objects +for the individual packages, parallel to the C<cust_pkg> argument. The +reason and reason_otaker arguments will be taken from those objects. =item ban - can be set true to ban this customer's credit card or ACH information, if present. =item nobill - can be set true to skip billing if it might otherwise be done. -=back - -Always returns a list: an empty list on success or a list of errors. - =cut -# nb that dates are not specified as valid options to this method - -sub cancel { +sub cancel_pkgs { my( $self, %opt ) = @_; - warn "$me cancel called on customer ". $self->custnum. " with options ". - join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" - if $DEBUG; + # we're going to cancel services, which is not reversible + die "cancel_pkgs cannot be run inside a transaction" + if $FS::UID::AutoCommit == 0; + + local $FS::UID::AutoCommit = 0; return ( 'access denied' ) unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); @@ -2164,26 +2201,101 @@ sub cancel { my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref; my $error = $ban->insert; - return ( $error ) if $error; + if ($error) { + dbh->rollback; + return ( $error ); + } } } - my @pkgs = $self->ncancelled_pkgs; + my @pkgs = @{ delete $opt{'cust_pkg'} }; + my $cancel_time = $opt{'time'} || time; + # bill all packages first, so we don't lose usage, service counts for + # bulk billing, etc. if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) { $opt{nobill} = 1; - my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 ); - warn "Error billing during cancel, custnum ". $self->custnum. ": $error" - if $error; + my $error = $self->bill( 'pkg_list' => [ @pkgs ], + 'cancel' => 1, + 'time' => $cancel_time ); + if ($error) { + warn "Error billing during cancel, custnum ". $self->custnum. ": $error"; + dbh->rollback; + return ( "Error billing during cancellation: $error" ); + } + } + dbh->commit; + + my @errors; + # try to cancel each service, the same way we would for individual packages, + # but in cancel weight order. + my @cust_svc = map { $_->cust_svc } @pkgs; + my @sorted_cust_svc = + map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; } @cust_svc + ; + warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ". + $self->custnum."\n" + if $DEBUG; + foreach my $cust_svc (@sorted_cust_svc) { + my $part_svc = $cust_svc->part_svc; + next if ( defined($part_svc) and $part_svc->preserve ); + # immediate cancel, no date option + # transactionize individually + my $error = try { $cust_svc->cancel } catch { $_ }; + if ( $error ) { + dbh->rollback; + push @errors, $error; + } else { + dbh->commit; + } + } + if (@errors) { + return @errors; } - warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/". - scalar(@pkgs). " packages for customer ". $self->custnum. "\n" + warn "$me cancelling ". scalar(@pkgs) ." package(s) for customer ". + $self->custnum. "\n" if $DEBUG; - grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs; + my @cprs; + if ($opt{'cust_pkg_reason'}) { + @cprs = @{ delete $opt{'cust_pkg_reason'} }; + } + my $null_reason; + foreach (@pkgs) { + my %lopt = %opt; + if (@cprs) { + my $cpr = shift @cprs; + if ( $cpr ) { + $lopt{'reason'} = $cpr->reasonnum; + $lopt{'reason_otaker'} = $cpr->otaker; + } else { + warn "no reason found when canceling package ".$_->pkgnum."\n"; + # we're not actually required to pass a reason to cust_pkg::cancel, + # but if we're getting to this point, something has gone awry. + $null_reason ||= FS::reason->new_or_existing( + reason => 'unknown reason', + type => 'Cancel Reason', + class => 'C', + ); + $lopt{'reason'} = $null_reason->reasonnum; + $lopt{'reason_otaker'} = $FS::CurrentUser::CurrentUser->username; + } + } + my $error = $_->cancel(%lopt); + if ( $error ) { + dbh->rollback; + push @errors, 'pkgnum '.$_->pkgnum.': '.$error; + } else { + dbh->commit; + } + } + + return @errors; } sub _banned_pay_hashref { @@ -2336,6 +2448,8 @@ Removes the I<paycvv> field from the database directly. If there is an error, returns the error, otherwise returns false. +DEPRECATED. Use L</remove_cvv_from_cust_payby> instead. + =cut sub remove_cvv { @@ -2877,6 +2991,73 @@ sub invoicing_list_emailonly_scalar { join(', ', $self->invoicing_list_emailonly); } +=item contact_list [ CLASSNUM, ... ] + +Returns a list of contacts (L<FS::contact> objects) for the customer. If +a list of contact classnums is given, returns only contacts in those +classes. If the pseudo-classnum 'invoice' is given, returns contacts that +are marked as invoice destinations. If '0' is given, also returns contacts +with no class. + +If no arguments are given, returns all contacts for the customer. + +=cut + +sub contact_list { + my $self = shift; + my $search = { + table => 'contact', + select => 'contact.*, cust_contact.invoice_dest', + addl_from => ' JOIN cust_contact USING (contactnum)', + extra_sql => ' WHERE cust_contact.custnum = '.$self->custnum, + }; + + my @orwhere; + my @classnums; + foreach (@_) { + if ( $_ eq 'invoice' ) { + push @orwhere, 'cust_contact.invoice_dest = \'Y\''; + } elsif ( $_ eq '0' ) { + push @orwhere, 'cust_contact.classnum is null'; + } elsif ( /^\d+$/ ) { + push @classnums, $_; + } else { + die "bad classnum argument '$_'"; + } + } + + if (@classnums) { + push @orwhere, 'cust_contact.classnum IN ('.join(',', @classnums).')'; + } + if (@orwhere) { + $search->{extra_sql} .= ' AND (' . + join(' OR ', map "( $_ )", @orwhere) . + ')'; + } + + qsearch($search); +} + +=item contact_list_email [ CLASSNUM, ... ] + +Same as L</contact_list>, but returns email destinations instead of contact +objects. + +=cut + +sub contact_list_email { + my $self = shift; + my @contacts = $self->contact_list(@_); + my @emails; + foreach my $contact (@contacts) { + foreach my $contact_email ($contact->contact_email) { + push @emails, + $contact->firstlast . ' <' . $contact_email->emailaddress . '>'; + } + } + @emails; +} + =item referral_custnum_cust_main Returns the customer who referred this customer (or the empty string, if @@ -3803,13 +3984,17 @@ sub status { shift->cust_status(@_); } sub cust_status { my $self = shift; + return $self->hashref->{cust_status} if $self->hashref->{cust_status}; for my $status ( FS::cust_main->statuses() ) { my $method = $status.'_sql'; my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g; my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr; $sth->execute( ($self->custnum) x $numnum ) or die "Error executing 'SELECT $sql': ". $sth->errstr; - return $status if $sth->fetchrow_arrayref->[0]; + if ( $sth->fetchrow_arrayref->[0] ) { + $self->hashref->{cust_status} = $status; + return $status; + } } } @@ -4301,7 +4486,10 @@ sub save_cust_payby { # compare to FS::cust_main::realtime_bop - check both to make sure working correctly if ( $payby eq 'CARD' && - grep { $_ eq cardtype($opt{'payinfo'}) } $conf->config('cvv-save') ) { + ( (grep { $_ eq cardtype($opt{'payinfo'}) } $conf->config('cvv-save')) + || $conf->exists('business-onlinepayment-verification') + ) + ) { $new->set( 'paycvv' => $opt{'paycvv'} ); } else { $new->set( 'paycvv' => ''); @@ -4448,6 +4636,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 @@ -4585,15 +4800,10 @@ Returns an SQL expression identifying un-cancelled cust_main records. =cut sub uncancelled_sql { uncancel_sql(@_); } -sub uncancel_sql { " - ( 0 < ( $select_count_pkgs - AND ( cust_pkg.cancel IS NULL - OR cust_pkg.cancel = 0 - ) - ) - OR 0 = ( $select_count_pkgs ) - ) -"; } +sub uncancel_sql { + my $self = shift; + "( NOT (".$self->cancelled_sql.") )"; #sensitive to cust_main-status_module +} =item balance_sql diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 1157ba98f..4821ce555 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -216,6 +216,9 @@ sub cancel_expired_pkgs { my @errors = (); + my @really_cancel_pkgs; + my @cancel_reasons; + CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); my $error; @@ -233,14 +236,22 @@ sub cancel_expired_pkgs { $error = '' if ref $error eq 'FS::cust_pkg'; } else { # just cancel it - $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, - 'reason_otaker' => $cpr->otaker, - 'time' => $time, - ) - : () - ); + + push @really_cancel_pkgs, $cust_pkg; + push @cancel_reasons, $cpr; + } - push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; + } + + if (@really_cancel_pkgs) { + + my %cancel_opt = ( 'cust_pkg' => \@really_cancel_pkgs, + 'cust_pkg_reason' => \@cancel_reasons, + 'time' => $time, + ); + + push @errors, $self->cancel_pkgs(%cancel_opt); + } join(' / ', @errors); @@ -1067,6 +1078,9 @@ sub _make_lines { my $recur_billed_currency = ''; my $recur_billed_amount = 0; my $sdate; + + my $override_quantity; + # Conditions for billing the recurring fee: # - the package doesn't have a future start date # - and it's not suspended @@ -1167,6 +1181,11 @@ sub _make_lines { $recur_billed_amount = delete $param{'billed_amount'}; } + if ( $param{'override_quantity'} ) { + $override_quantity = $param{'override_quantity'}; + $unitrecur = $recur / $override_quantity; + } + if ( $increment_next_bill ) { my $next_bill; @@ -1241,7 +1260,7 @@ sub _make_lines { } } - } + } # end of recurring fee warn "\$setup is undefined" unless defined($setup); warn "\$recur is undefined" unless defined($recur); @@ -1305,14 +1324,14 @@ sub _make_lines { my $cust_bill_pkg = new FS::cust_bill_pkg { 'pkgnum' => $cust_pkg->pkgnum, 'setup' => $setup, - 'unitsetup' => $unitsetup, + 'unitsetup' => sprintf('%.2f', $unitsetup), 'setup_billed_currency' => $setup_billed_currency, 'setup_billed_amount' => $setup_billed_amount, 'recur' => $recur, - 'unitrecur' => $unitrecur, + 'unitrecur' => sprintf('%.2f', $unitrecur), 'recur_billed_currency' => $recur_billed_currency, 'recur_billed_amount' => $recur_billed_amount, - 'quantity' => $cust_pkg->quantity, + 'quantity' => $override_quantity || $cust_pkg->quantity, 'details' => \@details, 'discounts' => [ @setup_discounts, @recur_discounts ], 'hidden' => $part_pkg->hidden, diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 1cd2aba5b..3e4a438d6 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -5,7 +5,7 @@ use vars qw( $conf $DEBUG $me ); use vars qw( $realtime_bop_decline_quiet ); #ugh use Carp; use Data::Dumper; -use Business::CreditCard 0.28; +use Business::CreditCard 0.35; use FS::UID qw( dbh ); use FS::Record qw( qsearch qsearchs ); use FS::payby; @@ -73,14 +73,14 @@ sub realtime_cust_payby { =item realtime_collect [ OPTION => VALUE ... ] Attempt to collect the customer's current balance with a realtime credit -card, electronic check, or phone bill transaction (see realtime_bop() below). +card or electronic check transaction (see realtime_bop() below). Returns the result of realtime_bop(): nothing, an error message, or a hashref of state information for a third-party transaction. Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum> -I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified +I<method> is one of: I<CC> or I<ECHECK>. If none is specified then it is deduced from the customer record. If no I<amount> is specified, then the customer balance is used. @@ -133,13 +133,13 @@ sub realtime_collect { =item realtime_bop { [ ARG => VALUE ... ] } -Runs a realtime credit card, ACH (electronic check) or phone bill transaction +Runs a realtime credit card or ACH (electronic check) transaction via a Business::OnlinePayment realtime gateway. See L<http://420.am/business-onlinepayment> for supported gateways. Required arguments in the hashref are I<method>, and I<amount> -Available methods are: I<CC>, I<ECHECK>, I<LEC>, and I<PAYPAL> +Available methods are: I<CC>, I<ECHECK>, or I<PAYPAL> Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id> @@ -355,10 +355,38 @@ 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', - 'LEC' => 'LECB', 'PAYPAL' => 'PPAL', ); @@ -370,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') { @@ -511,11 +541,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'} @@ -557,6 +584,8 @@ sub realtime_bop { ? uc($options{'paytype'}) : uc($self->getfield('paytype')) || 'PERSONAL CHECKING'; + $content{company} = $self->company if $self->company; + if ( $content{account_type} =~ /BUSINESS/i && $self->company ) { $content{account_name} = $self->company; } else { @@ -575,8 +604,6 @@ sub realtime_bop { ? $options{'ss'} : $self->ss; - } elsif ( $options{method} eq 'LEC' ) { - $content{phone} = $options{payinfo}; } else { die "unknown method ". $options{method}; } @@ -765,10 +792,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"; } @@ -778,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 @@ -870,8 +886,8 @@ sub fake_bop { # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ] # -# Wraps up processing of a realtime credit card, ACH (electronic check) or -# phone bill transaction. +# Wraps up processing of a realtime credit card or ACH (electronic check) +# transaction. sub _realtime_bop_result { my( $self, $cust_pay_pending, $transaction, %options ) = @_; @@ -1167,8 +1183,8 @@ sub _realtime_bop_result { =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ] -Verifies successful third party processing of a realtime credit card, -ACH (electronic check) or phone bill transaction via a +Verifies successful third party processing of a realtime credit card or +ACH (electronic check) transaction via a Business::OnlineThirdPartyPayment realtime gateway. See L<http://420.am/business-onlinethirdpartypayment> for supported gateways. @@ -1325,11 +1341,11 @@ sub default_payment_gateway { =item realtime_refund_bop METHOD [ OPTION => VALUE ... ] -Refunds a realtime credit card, ACH (electronic check) or phone bill transaction +Refunds a realtime credit card or ACH (electronic check) transaction via a Business::OnlinePayment realtime gateway. See L<http://420.am/business-onlinepayment> for supported gateways. -Available methods are: I<CC>, I<ECHECK> and I<LEC> +Available methods are: I<CC> or I<ECHECK> Available options are: I<amount>, I<reasonnum>, I<paynum>, I<paydate> @@ -1623,8 +1639,7 @@ sub realtime_refund_bop { $content{account_name} = $payname; $content{customer_org} = $self->company ? 'B' : 'I'; $content{customer_ssn} = $self->ss; - } elsif ( $options{method} eq 'LEC' ) { - $content{phone} = $payinfo = $self->payinfo; + } #then try refund @@ -1698,6 +1713,407 @@ sub realtime_refund_bop { } +=item realtime_verify_bop [ OPTION => VALUE ... ] + +Runs an authorization-only transaction for $1 against this credit card (if +successful, immediatly reverses the authorization). + +Returns the empty string if the authorization was sucessful, or an error +message otherwise. + +I<payinfo> + +I<payname> + +I<paydate> specifies the expiration date for a credit card overriding the +value from the customer record or the payment record. Specified as yyyy-mm-dd + +#The additional options I<address1>, I<address2>, I<city>, I<state>, +#I<zip> are also available. Any of these options, +#if set, will override the value from the customer record. + +=cut + +#Available methods are: I<CC> or I<ECHECK> + +#some false laziness w/realtime_bop and realtime_refund_bop, not enough to make +#it worth merging but some useful small subs should be pulled out +sub realtime_verify_bop { + my $self = shift; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + + my %options = (); + if (ref($_[0]) eq 'HASH') { + %options = %{$_[0]}; + } else { + %options = @_; + } + + if ( $DEBUG ) { + warn "$me realtime_verify_bop\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + ### + # select a gateway + ### + + my $payment_gateway = $self->_payment_gateway( \%options ); + my $namespace = $payment_gateway->gateway_namespace; + + eval "use $namespace"; + die $@ if $@; + + ### + # check for banned credit card/ACH + ### + + my $ban = FS::banned_pay->ban_search( + 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, + ); + return "Banned credit card" if $ban && $ban->bantype ne 'warn'; + + ### + # massage data + ### + + my $bop_content = $self->_bop_content(\%options); + return $bop_content unless ref($bop_content); + + my @invoicing_list = $self->invoicing_list_emailonly; + if ( $conf->exists('emailinvoiceautoalways') + || $conf->exists('emailinvoiceauto') && ! @invoicing_list + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + + my $email = ($conf->exists('business-onlinepayment-email-override')) + ? $conf->config('business-onlinepayment-email-override') + : $invoicing_list[0]; + + my $paydate = ''; + my %content = (); + + if ( $namespace eq 'Business::OnlinePayment' ) { + + if ( $options{method} eq 'CC' ) { + + $content{card_number} = $options{payinfo}; + $paydate = exists($options{'paydate'}) + ? $options{'paydate'} + : $self->paydate; + $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + + $content{cvv2} = $options{'paycvv'} + if length($options{'paycvv'}); + + my $paystart_month = exists($options{'paystart_month'}) + ? $options{'paystart_month'} + : $self->paystart_month; + + my $paystart_year = exists($options{'paystart_year'}) + ? $options{'paystart_year'} + : $self->paystart_year; + + $content{card_start} = "$paystart_month/$paystart_year" + if $paystart_month && $paystart_year; + + my $payissue = exists($options{'payissue'}) + ? $options{'payissue'} + : $self->payissue; + $content{issue_number} = $payissue if $payissue; + + } elsif ( $options{method} eq 'ECHECK' ){ + + #nop for checks (though it shouldn't be called...) + + } else { + die "unknown method ". $options{method}; + } + + } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { + #move along + } else { + die "unknown namespace $namespace"; + } + + ### + # run transaction(s) + ### + + warn "claiming mutex on customer ". $self->custnum. "\n" if $DEBUG > 1; + $self->select_for_update; #mutex ... just until we get our pending record in + warn "obtained mutex on customer ". $self->custnum. "\n" if $DEBUG > 1; + + #the checks here are intended to catch concurrent payments + #double-form-submission prevention is taken care of in cust_pay_pending::check + + #also check and make sure there aren't *other* pending payments for this cust + + my @pending = qsearch('cust_pay_pending', { + 'custnum' => $self->custnum, + 'status' => { op=>'!=', value=>'done' } + }); + + return "A payment is already being processed for this customer (". + join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ). + "); verification transaction aborted." + if scalar(@pending); + + #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out + + my $cust_pay_pending = new FS::cust_pay_pending { + 'custnum' => $self->custnum, + 'paid' => '1.00', + '_date' => '', + 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, + 'paymask' => $options{paymask}, + 'paydate' => $paydate, + #'recurring_billing' => $content{recurring_billing}, + 'pkgnum' => $options{'pkgnum'}, + 'status' => 'new', + 'gatewaynum' => $payment_gateway->gatewaynum || '', + 'session_id' => $options{session_id} || '', + #'jobnum' => $options{depend_jobnum} || '', + }; + $cust_pay_pending->payunique( $options{payunique} ) + if defined($options{payunique}) && length($options{payunique}); + + warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n" + if $DEBUG > 1; + my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted + return $cpp_new_err if $cpp_new_err; + + warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n" + if $DEBUG > 1; + warn Dumper($cust_pay_pending) if $DEBUG > 2; + + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $transaction->content( + 'type' => 'CC', + $self->_bop_auth(\%options), + 'action' => 'Authorization Only', + 'description' => $options{'description'}, + 'amount' => '1.00', + #'invoice_number' => $options{'invnum'}, + 'customer_id' => $self->custnum, + %$bop_content, + 'reference' => $cust_pay_pending->paypendingnum, #for now + 'callback_url' => $payment_gateway->gateway_callback_url, + 'cancel_url' => $payment_gateway->gateway_cancel_url, + 'email' => $email, + %content, #after + ); + + $cust_pay_pending->status('pending'); + my $cpp_pending_err = $cust_pay_pending->replace; + return $cpp_pending_err if $cpp_pending_err; + + warn Dumper($transaction) if $DEBUG > 2; + + unless ( $BOP_TESTING ) { + $transaction->test_transaction(1) + if $conf->exists('business-onlinepayment-test_transaction'); + $transaction->submit(); + } else { + if ( $BOP_TESTING_SUCCESS ) { + $transaction->is_success(1); + $transaction->authorization('fake auth'); + } else { + $transaction->is_success(0); + $transaction->error_message('fake failure'); + } + } + + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop'); + + if ( $transaction->is_success() ) { + + $cust_pay_pending->status('authorized'); + my $cpp_authorized_err = $cust_pay_pending->replace; + return $cpp_authorized_err if $cpp_authorized_err; + + my $auth = $transaction->authorization; + my $ordernum = $transaction->can('order_number') + ? $transaction->order_number + : ''; + + my $reverse = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $reverse->content( 'action' => 'Reverse Authorization', + $self->_bop_auth(\%options), + + # B:OP + 'amount' => '1.00', + 'authorization' => $transaction->authorization, + 'order_number' => $ordernum, + + # vsecure + 'result_code' => $transaction->result_code, + 'txn_date' => $transaction->txn_date, + + %content, + ); + $reverse->test_transaction(1) + if $conf->exists('business-onlinepayment-test_transaction'); + $reverse->submit(); + + 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; + + } else { + + 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 + $cust_pay_pending->status('done'); + $cust_pay_pending->statustext( $transaction->error_message || 'Unknown error' ); + # could also record failure_status here, + # but it's not supported by B::OP::vSecureProcessing... + # need a B::OP module with (reverse) auth only to test it with + my $cpp_declined_err = $cust_pay_pending->replace; + return $cpp_declined_err if $cpp_declined_err; + + } + + ### + # Tokenize + ### + + $self->_tokenize_card($transaction,$options{'payinfo'},$log); + + ### + # result handling + ### + + $transaction->is_success() ? '' : $transaction->error_message(); + +} + =back =head1 BUGS diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm index 2b3634a83..ee5bddaca 100644 --- a/FS/FS/cust_main/Packages.pm +++ b/FS/FS/cust_main/Packages.pm @@ -10,6 +10,7 @@ use FS::contact; # for attach_pkgs use FS::cust_location; # our ($DEBUG, $me) = (0, '[FS::cust_main::Packages]'); +our $skip_label_sort = 0; =head1 NAME @@ -443,7 +444,9 @@ sub all_pkgs { @cust_pkg = $self->_cust_pkg($extra_qsearch); } + local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort}; map { $_ } sort sort_packages @cust_pkg; + } =item cust_pkg @@ -492,10 +495,31 @@ sub ncancelled_pkgs { } + local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort}; sort sort_packages @cust_pkg; } +=item cancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ] + +Returns all cancelled packages (see L<FS::cust_pkg>) for this customer. + +=cut + +sub cancelled_pkgs { + my $self = shift; + my $extra_qsearch = ref($_[0]) ? shift : { @_ }; + + return $self->num_cancelled_pkgs($extra_qsearch) unless wantarray; + + $extra_qsearch->{'extra_sql'} .= + ' AND cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel > 0 '; + + local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort}; + + sort sort_packages $self->_cust_pkg($extra_qsearch); +} + sub _cust_pkg { my $self = shift; my $extra_qsearch = ref($_[0]) ? shift : {}; @@ -534,7 +558,8 @@ sub sort_packages { return 0 if !$a_num_cust_svc && !$b_num_cust_svc; return -1 if $a_num_cust_svc && !$b_num_cust_svc; return 1 if !$a_num_cust_svc && $b_num_cust_svc; - return 0 if $a_num_cust_svc + $b_num_cust_svc > 20; #for perf, just give up + return 0 if $skip_label_sort + || $a_num_cust_svc + $b_num_cust_svc > 20; #for perf, just give up my @a_cust_svc = $a->cust_svc_unsorted; my @b_cust_svc = $b->cust_svc_unsorted; return 0 if !scalar(@a_cust_svc) && !scalar(@b_cust_svc); @@ -723,6 +748,104 @@ sub num_pkgs { $sth->fetchrow_arrayref->[0]; } +=item display_recurring + +Returns an array of hash references, one for each recurring freq +on billable customer packages, with keys of freq, freq_pretty and amount +(the amount that this customer will next be charged at the given frequency.) + +Results will be numerically sorted by freq. + +Only intended for display purposes, not used for actual billing. + +=cut + +sub display_recurring { + my $cust_main = shift; + + my $sth = dbh->prepare(" + SELECT DISTINCT freq FROM cust_pkg LEFT JOIN part_pkg USING (pkgpart) + WHERE freq IS NOT NULL AND freq != '0' + AND ( cancel IS NULL OR cancel = 0 ) + AND custnum = ? + ") or die $DBI::errstr; + + $sth->execute($cust_main->custnum) or die $sth->errstr; + + #not really a numeric sort because freqs can actually be all sorts of things + # but good enough for the 99% cases of ordering monthly quarterly annually + my @freqs = sort { $a <=> $b } map { $_->[0] } @{ $sth->fetchall_arrayref }; + + $sth->finish; + + my @out; + + foreach my $freq (@freqs) { + + my @cust_pkg = qsearch({ + 'table' => 'cust_pkg', + 'addl_from' => 'LEFT JOIN part_pkg USING (pkgpart)', + 'hashref' => { 'custnum' => $cust_main->custnum, }, + 'extra_sql' => 'AND ( cancel IS NULL OR cancel = 0 ) + AND freq = '. dbh->quote($freq), + 'order_by' => 'ORDER BY COALESCE(start_date,0), pkgnum', # to ensure old pkgs come before change_to_pkg + }) or next; + + my $freq_pretty = $cust_pkg[0]->part_pkg->freq_pretty; + + my $amount = 0; + my $skip_pkg = {}; + foreach my $cust_pkg (@cust_pkg) { + my $part_pkg = $cust_pkg->part_pkg; + next if $cust_pkg->susp + && ! $cust_pkg->option('suspend_bill') + && ( ! $part_pkg->option('suspend_bill') + || $cust_pkg->option('no_suspend_bill') + ); + + #pkg change handling + next if $skip_pkg->{$cust_pkg->pkgnum}; + if ($cust_pkg->change_to_pkgnum) { + #if change is on or before next bill date, use new pkg + next if $cust_pkg->expire <= $cust_pkg->bill; + #if change is after next bill date, use old (this) pkg + $skip_pkg->{$cust_pkg->change_to_pkgnum} = 1; + } + + my $pkg_amount = 0; + + #add recurring amounts for this package and its billing add-ons + foreach my $l_part_pkg ( $part_pkg->self_and_bill_linked ) { + $pkg_amount += $l_part_pkg->base_recur($cust_pkg); + } + + #subtract amounts for any active discounts + #(there should only be one at the moment, otherwise this makes no sense) + foreach my $cust_pkg_discount ( $cust_pkg->cust_pkg_discount_active ) { + my $discount = $cust_pkg_discount->discount; + #and only one of these for each + $pkg_amount -= $discount->amount; + $pkg_amount -= $amount * $discount->percent/100; + } + + $pkg_amount *= ( $cust_pkg->quantity || 1 ); + + $amount += $pkg_amount; + + } #foreach $cust_pkg + + next unless $amount; + push @out, { + 'freq' => $freq, + 'freq_pretty' => $freq_pretty, + 'amount' => $amount, + }; + + } #foreach $freq + + return @out; +} + =back =head1 BUGS diff --git a/FS/FS/cust_main/Search.pm b/FS/FS/cust_main/Search.pm index c8a084c9b..e02114016 100644 --- a/FS/FS/cust_main/Search.pm +++ b/FS/FS/cust_main/Search.pm @@ -514,11 +514,9 @@ none or one). sub email_search { my %options = @_; - local($DEBUG) = 1; - my $email = delete $options{'email'}; - #we're only being used by RT at the moment... no agent virtualization yet + #no agent virtualization yet #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql; my @cust_main = (); @@ -1013,6 +1011,10 @@ sub search { 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) '; } + # always make referral available in results + # (maybe we should be using FS::UI::Web::join_cust_main instead?) + $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) '; + my $count_query = "SELECT COUNT(*) FROM cust_main $addl_from $extra_sql"; my @select = ( diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm index 9a2a9d7b1..195574627 100644 --- a/FS/FS/cust_main_Mixin.pm +++ b/FS/FS/cust_main_Mixin.pm @@ -210,19 +210,9 @@ a customer. sub cust_status { my $self = shift; return $self->cust_unlinked_msg unless $self->cust_linked; - - #FS::cust_main::status($self) - #false laziness w/actual cust_main::status - # (make sure FS::cust_main methods are called) - for my $status (qw( prospect active inactive suspended cancelled )) { - my $method = $status.'_sql'; - my $sql = FS::cust_main->$method();; - my $numnum = ( $sql =~ s/cust_main\.custnum/?/g ); - my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr; - $sth->execute( ($self->custnum) x $numnum ) - or die "Error executing 'SELECT $sql': ". $sth->errstr; - return $status if $sth->fetchrow_arrayref->[0]; - } + my $cust_main = $self->cust_main; + return $self->cust_unlinked_msg unless $cust_main; + return $cust_main->cust_status; } =item ucfirst_cust_status @@ -383,6 +373,12 @@ HTML body Text body +=item to_contact_classnum + +The customer contact class (or classes, as a comma-separated list) to send +the message to. If unspecified, will be sent to any contacts that are marked +as invoice destinations (the equivalent of specifying 'invoice'). + =back Returns an error message, or false for success. @@ -406,6 +402,7 @@ sub email_search_result { my $subject = delete $param->{subject}; my $html_body = delete $param->{html_body}; my $text_body = delete $param->{text_body}; + my $to_contact_classnum = delete $param->{to_contact_classnum}; my $error = ''; my $job = delete $param->{'job'} @@ -471,6 +468,7 @@ sub email_search_result { my $cust_msg = $msg_template->prepare( 'cust_main' => $cust_main, 'object' => $obj, + 'to_contact_classnum' => $to_contact_classnum, ); # For non-cust_main searches, we avoid duplicates based on message @@ -665,15 +663,29 @@ sub unsuspend_balance { my $self = shift; my $cust_main = $self->cust_main; my $conf = $self->conf; - my $setting = $conf->config('unsuspend_balance'); + my $setting = $conf->config('unsuspend_balance') or return; my $maxbalance; if ($setting eq 'Zero') { $maxbalance = 0; + + # kind of a pain to load/check all cust_bill instead of just open ones, + # but if for some reason payment gets applied to later bills before + # earlier ones, we still want to consider the later ones as allowable balance } elsif ($setting eq 'Latest invoice charges') { my @cust_bill = $cust_main->cust_bill(); my $cust_bill = $cust_bill[-1]; #always want the most recent one - return unless $cust_bill; - $maxbalance = $cust_bill->charged || 0; + if ($cust_bill) { + $maxbalance = $cust_bill->charged || 0; + } else { + $maxbalance = 0; + } + } elsif ($setting eq 'Charges not past due') { + my $now = time; + $maxbalance = 0; + foreach my $cust_bill ($cust_main->cust_bill()) { + next unless $now <= ($cust_bill->due_date || $cust_bill->_date); + $maxbalance += $cust_bill->charged || 0; + } } elsif (length($setting)) { warn "Unrecognized unsuspend_balance setting $setting"; return; 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.pm b/FS/FS/cust_pay.pm index 331a15623..e0a7143c4 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -97,6 +97,10 @@ Payment Type (See L<FS::payinfo_Mixin> for valid values) Payment Information (See L<FS::payinfo_Mixin> for data format) +=item paycardtype + +Credit card type, if appropriate; autodetected. + =item paymask Masked payinfo (See L<FS::payinfo_Mixin> for how this works) @@ -1205,6 +1209,12 @@ sub _upgrade_data { #class method process_upgrade_paybatch(); } } + + ### + # set paycardtype + ### + $class->upgrade_set_cardtype; + } sub process_upgrade_paybatch { diff --git a/FS/FS/cust_pay_pending.pm b/FS/FS/cust_pay_pending.pm index 1a5420385..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. @@ -470,6 +490,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_pay_void.pm b/FS/FS/cust_pay_void.pm index 8d37a58b5..29540d1c6 100644 --- a/FS/FS/cust_pay_void.pm +++ b/FS/FS/cust_pay_void.pm @@ -74,6 +74,10 @@ Payment Type (See L<FS::payinfo_Mixin> for valid values) card number, check #, or comp issuer (4-8 lowercase alphanumerics; think username), respectively +=item cardtype + +Credit card type, if appropriate. + =item paybatch text field for tracking card processing diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index 030aed6f2..e4a1d193c 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -115,6 +115,9 @@ paytype payip +=item paycardtype + +The credit card type (deduced from the card number). =back @@ -196,10 +199,6 @@ sub replace { ? shift : $self->replace_old; - if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) { - $self->paycvv($old->paycvv); - } - if ( $self->payby =~ /^(CARD|DCRD)$/ && ( $self->payinfo =~ /xx/ || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/ @@ -221,6 +220,17 @@ sub replace { $self->payinfo($new_account.'@'.$new_aba); } + # 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(''); + } + } + local($ignore_expired_card) = 1 if $old->payby =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/ @@ -237,6 +247,11 @@ sub replace { { my $error = $self->check_payinfo_cardtype; return $error if $error; + + if ( $conf->exists('business-onlinepayment-verification') ) { + $error = $self->verify; + return $error if $error; + } } local $SIG{HUP} = 'IGNORE'; @@ -319,6 +334,13 @@ sub check { # Need some kind of global flag to accept invalid cards, for testing # on scrubbed data. #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) { + + # In this block: detect card type; reject credit card / account numbers that + # are impossible or banned; reject other payment features (date, CVV length) + # that are inappropriate for the card type. + # However, if the payinfo is encrypted then just detect card type and assume + # the other checks were already done. + if ( !$ignore_invalid_card && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) { @@ -331,9 +353,12 @@ sub check { validate($payinfo) or return gettext('invalid_card'); # . ": ". $self->payinfo; - return gettext('unknown_card_type') - if $self->payinfo !~ /^99\d{14}$/ #token - && cardtype($self->payinfo) eq "Unknown"; + my $cardtype = cardtype($payinfo); + $cardtype = 'Tokenized' if $self->payinfo =~ /^99\d{14}$/; #token + + return gettext('unknown_card_type') if $cardtype eq "Unknown"; + + $self->set('paycardtype', $cardtype); unless ( $ignore_banned_card ) { my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } ); @@ -355,7 +380,7 @@ sub check { } if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) { - if ( cardtype($self->payinfo) eq 'American Express card' ) { + if ( $cardtype eq 'American Express card' ) { $self->paycvv =~ /^(\d{4})$/ or return "CVV2 (CID) for American Express cards is four digits."; $self->paycvv($1); @@ -368,7 +393,6 @@ sub check { $self->paycvv(''); } - my $cardtype = cardtype($payinfo); if ( $cardtype =~ /^(Switch|Solo)$/i ) { return "Start date or issue number is required for $cardtype cards" @@ -426,6 +450,15 @@ sub check { } } + } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) { + # either ignoring invalid cards, or we can't decrypt the payinfo, but + # try to detect the card type anyway. this never returns failure, so + # the contract of $ignore_invalid_cards is maintained. + $self->set('paycardtype', cardtype($self->paymask)); + } else { + $self->set('paycardtype', ''); + } + # } elsif ( $self->payby eq 'PREPAY' ) { # # my $payinfo = $self->payinfo; @@ -437,8 +470,6 @@ sub check { # unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } ); # $self->paycvv(''); - } - if ( $self->payby =~ /^(CHEK|DCHK)$/ ) { $self->paydate(''); @@ -446,6 +477,7 @@ sub check { } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) { # shouldn't payinfo_check do this? + # (except we don't ever call payinfo_check from here) return "Expiration date required" if $self->paydate eq '' || $self->paydate eq '-'; @@ -489,7 +521,11 @@ sub check { } - ### + if ( ! $self->custpaybynum + && $conf->exists('business-onlinepayment-verification') ) { + $error = $self->verify; + return $error if $error; + } $self->SUPER::check; } @@ -504,10 +540,14 @@ sub check_payinfo_cardtype { my $payinfo = $self->payinfo; $payinfo =~ s/\D//g; - return '' if $payinfo =~ /^99\d{14}$/; #token + if ( $payinfo =~ /^99\d{14}$/ ) { + $self->set('paycardtype', 'Tokenized'); + return ''; + } my %bop_card_types = map { $_=>1 } values %{ card_types() }; my $cardtype = cardtype($payinfo); + $self->set('paycardtype', $cardtype); return "$cardtype not accepted" unless $bop_card_types{$cardtype}; @@ -583,7 +623,7 @@ sub label { my $self = shift; my $name = $self->payby =~ /^(CARD|DCRD)$/ - && cardtype($self->paymask) || FS::payby->shortname($self->payby); + && $self->paycardtype || FS::payby->shortname($self->payby); ( $self->payby =~ /^(CARD|CHEK)$/ ? $weight{$self->weight}. ' automatic ' : 'Manual ' @@ -617,6 +657,44 @@ sub realtime_bop { } +=item verify + +=cut + +sub verify { + my $self = shift; + return '' unless $self->payby =~ /^(CARD|DCRD)$/; + + my %opt = (); + + # false laziness with check + my( $m, $y ); + if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) { + ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" ); + } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $2, "19$1" ); + } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $3, "20$2" ); + } else { + return "Illegal expiration date: ". $self->paydate; + } + $m = sprintf('%02d',$m); + $opt{paydate} = "$y-$m-01"; + + $opt{$_} = $self->$_() for qw( payinfo payname paycvv ); + + if ( $self->locationnum ) { + my $cust_location = $self->cust_location; + $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip ); + } + + $self->cust_main->realtime_verify_bop({ + 'method' => FS::payby->payby2bop( $self->payby ), + %opt, + }); + +} + =item paytypes Returns a list of valid values for the paytype field (bank account type for @@ -661,6 +739,9 @@ sub cgi_hash_callback { 'CARD' => 'DCRD', 'CHEK' => 'DCHK', ); + # the payby selector gives the choice of CARD or CHEK (or others, but + # those are the ones with auto and on-demand versions). if the user didn't + # choose a weight, then they mean DCRD/DCHK. $hashref->{payby} = $noauto{$hashref->{payby}} if ! $hashref->{weight} && exists $noauto{$hashref->{payby}}; @@ -784,6 +865,9 @@ sub search_sql { ' LEFT JOIN cust_location AS '.$pre.'location '. 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) '; } + # always make referral available in results + # (maybe we should be using FS::UI::Web::join_cust_main instead?) + $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) '; my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql"; @@ -812,6 +896,18 @@ sub search_sql { =back +=cut + +sub _upgrade_data { + + my $class = shift; + local $ignore_banned_card = 1; + local $ignore_expired_card = 1; + local $ignore_invalid_card = 1; + $class->upgrade_set_cardtype; + +} + =head1 BUGS =head1 SEE ALSO diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 85234cfec..bbb281ade 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -442,6 +442,21 @@ sub insert { my $conf = new FS::Conf; + if ($self->locationnum) { + my @part_export = + map qsearch( 'part_export', {exportnum=>$_} ), + $conf->config('cust_location-exports'); #, $agentnum + + foreach my $part_export ( @part_export ) { + my $error = $part_export->export_pkg_location($self); #, @$export_args); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "exporting to ". $part_export->exporttype. + " (transaction rolled back): $error"; + } + } + } + if ( ! $import && $conf->config('ticket_system') && $options{ticket_subject} ) { #this init stuff is still inefficient, but at least its limited to @@ -518,6 +533,7 @@ sub delete { # cust_bill_pay.pkgnum (wtf, shouldn't reference pkgnum) # cust_pkg_usage.pkgnum # cust_pkg.uncancel_pkgnum, change_pkgnum, main_pkgnum, and change_to_pkgnum + # rt_field_charge.pkgnum # cust_svc is handled by canceling the package before deleting it # cust_pkg_option is handled via option_Common @@ -696,6 +712,24 @@ sub replace { } } + # also run exports if removing locationnum? + # doesn't seem to happen, and we don't export blank locationnum on insert... + if ($new->locationnum and ($new->locationnum != $old->locationnum)) { + my $conf = new FS::Conf; + my @part_export = + map qsearch( 'part_export', {exportnum=>$_} ), + $conf->config('cust_location-exports'); #, $agentnum + + foreach my $part_export ( @part_export ) { + my $error = $part_export->export_pkg_location($new); #, @$export_args); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "exporting to ". $part_export->exporttype. + " (transaction rolled back): $error"; + } + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; @@ -1096,6 +1130,166 @@ sub cancel_if_expired { ''; } +=item uncancel_svc_x + +For cancelled cust_pkg, returns a list of new, uninserted FS::svc_X records +for services that would be inserted by L</uncancel>. Returned objects also +include the field _h_svc_x, which contains the service history object. + +Set pkgnum before inserting. + +Accepts the following options: + +only_svcnum - arrayref of svcnum, only returns objects for these svcnum +(and only if they would otherwise be returned by this) + +=cut + +sub uncancel_svc_x { + my ($self, %opt) = @_; + + die 'uncancel_svc_x called on a non-cancelled cust_pkg' unless $self->get('cancel'); + + #find historical services within this timeframe before the package cancel + # (incompatible with "time" option to cust_pkg->cancel?) + my $fuzz = 2 * 60; #2 minutes? too much? (might catch separate unprovision) + # too little? (unprovisioing export delay?) + my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz ); + my @h_cust_svc = $self->h_cust_svc( $end, $start ); + + my @svc_x; + foreach my $h_cust_svc (@h_cust_svc) { + next if $opt{'only_svcnum'} && !(grep { $_ == $h_cust_svc->svcnum } @{$opt{'only_svcnum'}}); + # filter out services that still exist on this package (ie preserved svcs) + # but keep services that have since been provisioned on another package (for informational purposes) + next if qsearchs('cust_svc',{ 'svcnum' => $h_cust_svc->svcnum, 'pkgnum' => $self->pkgnum }); + my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start ); + next unless $h_svc_x; # this probably doesn't happen, but just in case + (my $table = $h_svc_x->table) =~ s/^h_//; + require "FS/$table.pm"; + my $class = "FS::$table"; + my $svc_x = $class->new( { + 'svcpart' => $h_cust_svc->svcpart, + '_h_svc_x' => $h_svc_x, + map { $_ => $h_svc_x->get($_) } fields($table) + } ); + + # radius_usergroup + if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) { + $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] ); + } + + #these are pretty rare, but should handle them + # - dsl_device (mac addresses) + # - phone_device (mac addresses) + # - dsl_note (ikano notes) + # - domain_record (i.e. restore DNS information w/domains) + # - inventory_item(?) (inventory w/un-cancelling service?) + # - nas (svc_broaband nas stuff) + #this stuff is unused in the wild afaik + # - mailinglistmember + # - router.svcnum? + # - svc_domain.parent_svcnum? + # - acct_snarf (ancient mail fetching config) + # - cgp_rule (communigate) + # - cust_svc_option (used by our Tron stuff) + # - acct_rt_transaction (used by our time worked stuff) + + push @svc_x, $svc_x; + } + return @svc_x; +} + +=item uncancel_svc_summary + +Returns an array of hashrefs, one for each service that could +potentially be reprovisioned by L</uncancel>, with the following keys: + +svcpart + +svc + +uncancel_svcnum + +label - from history table if not currently calculable, undefined if it can't be loaded + +reprovisionable - 1 if test reprovision succeeded, otherwise 0 + +num_cust_svc - number of svcs for this svcpart, only if summarizing (see below) + +Cannot be run from within a transaction. Performs inserts +to test the results, and then rolls back the transaction. +Does not perform exports, so does not catch if export would fail. + +Also accepts the following options: + +no_test_reprovision - skip the test inserts (reprovisionable field will not exist) + +summarize_size - if true, returns a single summary record for svcparts with at +least this many svcs, will have key num_cust_svc but not uncancel_svcnum, label or reprovisionable + +=cut + +sub uncancel_svc_summary { + my ($self, %opt) = @_; + + die 'uncancel_svc_summary called on a non-cancelled cust_pkg' unless $self->get('cancel'); + die 'uncancel_svc_summary called from within a transaction' unless $FS::UID::AutoCommit; + + local $FS::svc_Common::noexport_hack = 1; # very important not to run exports!!! + local $FS::UID::AutoCommit = 0; + + # sort by svcpart, to check summarize_size + my $uncancel_svc_x = {}; + foreach my $svc_x (sort { $a->{'svcpart'} <=> $b->{'svcpart'} } $self->uncancel_svc_x) { + $uncancel_svc_x->{$svc_x->svcpart} = [] unless $uncancel_svc_x->{$svc_x->svcpart}; + push @{$uncancel_svc_x->{$svc_x->svcpart}}, $svc_x; + } + + my @out; + foreach my $svcpart (keys %$uncancel_svc_x) { + my @svcpart_svc_x = @{$uncancel_svc_x->{$svcpart}}; + if ($opt{'summarize_size'} && (@svcpart_svc_x >= $opt{'summarize_size'})) { + my $svc_x = $svcpart_svc_x[0]; #grab first one for access to $part_svc + my $part_svc = $svc_x->part_svc; + push @out, { + 'svcpart' => $part_svc->svcpart, + 'svc' => $part_svc->svc, + 'num_cust_svc' => scalar(@svcpart_svc_x), + }; + } else { + foreach my $svc_x (@svcpart_svc_x) { + my $part_svc = $svc_x->part_svc; + my $out = { + 'svcpart' => $part_svc->svcpart, + 'svc' => $part_svc->svc, + 'uncancel_svcnum' => $svc_x->get('_h_svc_x')->svcnum, + }; + $svc_x->pkgnum($self->pkgnum); # provisioning services on a canceled package, will be rolled back + my $insert_error; + unless ($opt{'no_test_reprovision'}) { + # avoid possibly fatal errors from missing linked records + eval { $insert_error = $svc_x->insert }; + $insert_error ||= $@; + } + if ($opt{'no_test_reprovision'} or $insert_error) { + # avoid possibly fatal errors from missing linked records + eval { $out->{'label'} = $svc_x->label }; + eval { $out->{'label'} = $svc_x->get('_h_svc_x')->label } unless defined($out->{'label'}); + $out->{'reprovisionable'} = 0 unless $opt{'no_test_reprovision'}; + } else { + $out->{'label'} = $svc_x->label; + $out->{'reprovisionable'} = 1; + } + push @out, $out; + } + } + } + + dbh->rollback; + return @out; +} + =item uncancel "Un-cancels" this package: Orders a new package with the same custnum, pkgpart, @@ -1108,6 +1302,8 @@ svc_fatal: service provisioning errors are fatal svc_errors: pass an array reference, will be filled in with any provisioning errors +only_svcnum: arrayref, only attempt to re-provision these cancelled services + main_pkgnum: link the package as a supplemental package of this one. For internal use only. @@ -1164,32 +1360,12 @@ sub uncancel { # insert services ## - #find historical services within this timeframe before the package cancel - # (incompatible with "time" option to cust_pkg->cancel?) - my $fuzz = 2 * 60; #2 minutes? too much? (might catch separate unprovision) - # too little? (unprovisioing export delay?) - my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz ); - my @h_cust_svc = $self->h_cust_svc( $end, $start ); - my @svc_errors; - foreach my $h_cust_svc (@h_cust_svc) { - my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start ); - #next unless $h_svc_x; #should this happen? - (my $table = $h_svc_x->table) =~ s/^h_//; - require "FS/$table.pm"; - my $class = "FS::$table"; - my $svc_x = $class->new( { - 'pkgnum' => $cust_pkg->pkgnum, - 'svcpart' => $h_cust_svc->svcpart, - map { $_ => $h_svc_x->get($_) } fields($table) - } ); - - # radius_usergroup - if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) { - $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] ); - } + foreach my $svc_x ($self->uncancel_svc_x('only_svcnum' => $options{'only_svcnum'})) { + $svc_x->pkgnum($cust_pkg->pkgnum); my $svc_error = $svc_x->insert; + if ( $svc_error ) { if ( $options{svc_fatal} ) { $dbh->rollback if $oldAutoCommit; @@ -1213,23 +1389,7 @@ sub uncancel { } } # svc_fatal } # svc_error - } #foreach $h_cust_svc - - #these are pretty rare, but should handle them - # - dsl_device (mac addresses) - # - phone_device (mac addresses) - # - dsl_note (ikano notes) - # - domain_record (i.e. restore DNS information w/domains) - # - inventory_item(?) (inventory w/un-cancelling service?) - # - nas (svc_broaband nas stuff) - #this stuff is unused in the wild afaik - # - mailinglistmember - # - router.svcnum? - # - svc_domain.parent_svcnum? - # - acct_snarf (ancient mail fetching config) - # - cgp_rule (communigate) - # - cust_svc_option (used by our Tron stuff) - # - acct_rt_transaction (used by our time worked stuff) + } #foreach uncancel_svc_x ## # also move over any services that didn't unprovision at cancellation @@ -1272,14 +1432,15 @@ sub uncancel { =item unexpire -Cancels any pending expiration (sets the expire field to null). +Cancels any pending expiration (sets the expire field to null) +for this package and any supplemental packages. If there is an error, returns the error, otherwise returns false. =cut sub unexpire { - my( $self, %options ) = @_; + my( $self ) = @_; my $error; my $oldAutoCommit = $FS::UID::AutoCommit; @@ -1309,6 +1470,14 @@ sub unexpire { return $error; } + foreach my $supp_pkg ( $self->supplemental_pkgs ) { + $error = $supp_pkg->unexpire; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "unexpiring supplemental pkg#".$supp_pkg->pkgnum.": $error"; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; #no errors @@ -1916,14 +2085,15 @@ sub unsuspend { =item unadjourn -Cancels any pending suspension (sets the adjourn field to null). +Cancels any pending suspension (sets the adjourn field to null) +for this package and any supplemental packages. If there is an error, returns the error, otherwise returns false. =cut sub unadjourn { - my( $self, %options ) = @_; + my( $self ) = @_; my $error; my $oldAutoCommit = $FS::UID::AutoCommit; @@ -1960,6 +2130,14 @@ sub unadjourn { return $error; } + foreach my $supp_pkg ( $self->supplemental_pkgs ) { + $error = $supp_pkg->unadjourn; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "unadjourning supplemental pkg#".$supp_pkg->pkgnum.": $error"; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; #no errors @@ -2132,7 +2310,7 @@ sub change { my $time = time; - $hash{'setup'} = $time if $self->setup; + $hash{'setup'} = $time if $self->get('setup'); $hash{'change_date'} = $time; $hash{"change_$_"} = $self->$_() @@ -2153,16 +2331,18 @@ sub change { my $unused_credit = 0; my $keep_dates = $opt->{'keep_dates'}; - # Special case. If the pkgpart is changing, and the customer is - # going to be credited for remaining time, don't keep setup, bill, - # or last_bill dates, and DO pass the flag to cancel() to credit - # the customer. + # Special case. If the pkgpart is changing, and the customer is going to be + # credited for remaining time, don't keep setup, bill, or last_bill dates, + # and DO pass the flag to cancel() to credit the customer. If the old + # package had a setup date, set the new package's setup to the package + # change date so that it has the same status as before. if ( $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart and $self->part_pkg->option('unused_credit_change', 1) ) { $unused_credit = 1; $keep_dates = 0; - $hash{$_} = '' foreach qw(setup bill last_bill); + $hash{'last_bill'} = ''; + $hash{'bill'} = ''; } if ( $keep_dates ) { @@ -2350,6 +2530,21 @@ sub change { return "transferring package notes: $error"; } } + + # transfer scheduled expire/adjourn reasons + foreach my $action ('expire', 'adjourn') { + if ( $cust_pkg->get($action) ) { + my $reason = $self->last_cust_pkg_reason($action); + if ( $reason ) { + $reason->set('pkgnum', $cust_pkg->pkgnum); + $error = $reason->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "transferring $action reason: $error"; + } + } + } + } my @new_supp_pkgs; @@ -2430,6 +2625,19 @@ sub change { return "canceling old package: $error"; } + # transfer rt_field_charge, if we're not changing pkgpart + # after billing of old package, before billing of new package + if ( $same_pkgpart ) { + foreach my $rt_field_charge ($self->rt_field_charge) { + $rt_field_charge->set('pkgnum', $cust_pkg->pkgnum); + $error = $rt_field_charge->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "transferring rt_field_charge: $error"; + } + } + } + if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) { #$self->cust_main my $error = $cust_pkg->cust_main->bill( @@ -2502,6 +2710,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'} @@ -3261,16 +3479,15 @@ sub cust_svc_unsorted_arrayref { } my %search = ( - 'table' => 'cust_svc', - 'hashref' => { 'pkgnum' => $self->pkgnum }, + 'select' => 'cust_svc.*, part_svc.*', + 'table' => 'cust_svc', + 'hashref' => { 'pkgnum' => $self->pkgnum }, + 'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )', ); - if ( $opt{svcpart} ) { - $search{hashref}->{svcpart} = $opt{'svcpart'}; - } - if ( $opt{'svcdb'} ) { - $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) '; - $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} ); - } + $search{hashref}->{svcpart} = $opt{svcpart} + if $opt{svcpart}; + $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{svcdb} ) + if $opt{svcdb}; [ qsearch(\%search) ]; @@ -3765,23 +3982,27 @@ sub labels { map { [ $_->label ] } $self->cust_svc; } -=item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ] +=item h_labels END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ] Like the labels method, but returns historical information on services that were active as of END_TIMESTAMP and (optionally) not cancelled before START_TIMESTAMP. If MODE is 'I' (for 'invoice'), services with the I<pkg_svc.hidden> flag will be omitted. -Returns a list of lists, calling the label method for all (historical) services -(see L<FS::h_cust_svc>) of this billing item. +If LOCALE is passed, service definition names will be localized. + +Returns a list of lists, calling the label method for all (historical) +services (see L<FS::h_cust_svc>) of this billing item. =cut sub h_labels { my $self = shift; - warn "$me _h_labels called on $self\n" + my ($end, $start, $mode, $locale) = @_; + warn "$me h_labels\n" if $DEBUG; - map { [ $_->label(@_) ] } $self->h_cust_svc(@_); + map { [ $_->label($end, $start, $locale) ] } + $self->h_cust_svc($end, $start, $mode); } =item labels_short @@ -3794,15 +4015,15 @@ individual services rather than individual items. =cut sub labels_short { - shift->_labels_short( 'labels', @_ ); + shift->_labels_short( 'labels' ); # 'labels' takes no further arguments } -=item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ] +=item h_labels_short END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ] Like h_labels, except returns a simple flat list, and shortens long -(currently >5 or the cust_bill-max_same_services configuration value) lists of -identical services to one line that lists the service label and the number of -individual services rather than individual items. +(currently >5 or the cust_bill-max_same_services configuration value) lists +of identical services to one line that lists the service label and the +number of individual services rather than individual items. =cut @@ -3810,6 +4031,9 @@ sub h_labels_short { shift->_labels_short( 'h_labels', @_ ); } +# takes a method name ('labels' or 'h_labels') and all its arguments; +# maybe should be "shorten($self->h_labels( ... ) )" + sub _labels_short { my( $self, $method ) = ( shift, shift ); diff --git a/FS/FS/cust_pkg/API.pm b/FS/FS/cust_pkg/API.pm index f87eed345..837cf40cc 100644 --- a/FS/FS/cust_pkg/API.pm +++ b/FS/FS/cust_pkg/API.pm @@ -10,4 +10,66 @@ sub API_getinfo { } +# currently only handles location change... +# eventually have it handle all sorts of package changes +sub API_change { + my $self = shift; + my %opt = @_; + + return { 'error' => 'Cannot change canceled package' } + if $self->get('cancel'); + + my %changeopt; + + # update location--accepts raw fields OR location + my %location_hash; + foreach my $field ( qw( + locationname + address1 + address2 + city + county + state + zip + addr_clean + country + censustract + censusyear + location_type + location_number + location_kind + incorporated + ) ) { + $location_hash{$field} = $opt{$field} if $opt{$field}; + } + return { 'error' => 'Cannot pass both locationnum and location fields' } + if $opt{'locationnum'} && %location_hash; + + if (%location_hash) { + my $cust_location = FS::cust_location->new({ + 'custnum' => $self->custnum, + %location_hash, + }); + $changeopt{'cust_location'} = $cust_location; + } elsif ($opt{'locationnum'}) { + $changeopt{'locationnum'} = $opt{'locationnum'}; + } + + # not quite "nothing changed" because passed changes might be identical to current record, + # we don't currently check for that, don't want to imply that we do...but maybe we should? + return { 'error' => 'No changes passed to method' } + unless $changeopt{'cust_location'} || $changeopt{'locationnum'}; + + $changeopt{'keep_dates'} = 1; + + my $pkg_or_error = $self->change( \%changeopt ); + my $error = ref($pkg_or_error) ? '' : $pkg_or_error; + + return { 'error' => $error } if $error; + + # return all fields? we don't yet expose them through FS::API + return { map { $_ => $pkg_or_error->get($_) } qw( pkgnum locationnum ) }; + +} + 1; diff --git a/FS/FS/cust_pkg_reason.pm b/FS/FS/cust_pkg_reason.pm index d11d05e95..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; @@ -209,6 +209,54 @@ sub _upgrade_data { # class method FS::upgrade_journal->set_done('cust_pkg_reason__missing_reason'); } + # Fix misplaced expire/suspend reasons due to package change (RT#71623). + # These will look like: + # - there is an expire reason linked to pkg1 + # - pkg1 has been canceled before the reason's date + # - pkg2 was changed from pkg1, has an expire date equal to the reason's + # date, and has no expire reason (check this later) + + my $error; + foreach my $action ('expire', 'adjourn') { + # Iterate this, because a package could be scheduled to expire, then + # changed several times, and we need to walk the reason forward to the + # last one. + while(1) { + my @reasons = qsearch( + { + select => 'cust_pkg_reason.*', + table => 'cust_pkg_reason', + addl_from => ' JOIN cust_pkg pkg1 USING (pkgnum) + JOIN cust_pkg pkg2 ON (pkg1.pkgnum = pkg2.change_pkgnum)', + hashref => { 'action' => uc(substr($action, 0, 1)) }, + extra_sql => " AND pkg1.cancel IS NOT NULL + AND cust_pkg_reason.date > pkg1.cancel + AND pkg2.$action = cust_pkg_reason.date" + }); + last if !@reasons; + warn "Checking ".scalar(@reasons)." possible misplaced $action reasons.\n"; + foreach my $cust_pkg_reason (@reasons) { + my $new_pkg = qsearchs('cust_pkg', { change_pkgnum => $cust_pkg_reason->pkgnum }); + my $new_reason = $new_pkg->last_cust_pkg_reason($action); + if ($new_reason and $new_reason->_date == $new_pkg->get($action)) { + # the expiration reason has been recreated on the new package, so + # just delete the old one + warn "Cleaning $action reason from canceled pkg#" . + $cust_pkg_reason->pkgnum . "\n"; + $error = $cust_pkg_reason->delete; + } else { + # then the old reason needs to be transferred + warn "Moving $action reason from canceled pkg#" . + $cust_pkg_reason->pkgnum . + " to new pkg#" . $new_pkg->pkgnum ."\n"; + $cust_pkg_reason->set('pkgnum' => $new_pkg->pkgnum); + $error = $cust_pkg_reason->replace; + } + die $error if $error; + } + } + } + #still can't fill in an action? don't abort the upgrade local($ignore_empty_action) = 1; diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm index ced954036..4d2baa514 100644 --- a/FS/FS/cust_refund.pm +++ b/FS/FS/cust_refund.pm @@ -82,6 +82,10 @@ Payment Type (See L<FS::payinfo_Mixin> for valid payby values) Payment Information (See L<FS::payinfo_Mixin> for data format) +=item paycardtype + +Detected credit card type, if appropriate; autodetected. + =item paymask Masked payinfo (See L<FS::payinfo_Mixin> for how this works) @@ -472,6 +476,9 @@ sub _upgrade_data { # class method my ($class, %opts) = @_; $class->_upgrade_reasonnum(%opts); $class->_upgrade_otaker(%opts); + + local $ignore_empty_reasonnum = 1; + $class->upgrade_set_cardtype; } =back diff --git a/FS/FS/cust_svc.pm b/FS/FS/cust_svc.pm index d432747d9..08183b46c 100644 --- a/FS/FS/cust_svc.pm +++ b/FS/FS/cust_svc.pm @@ -3,7 +3,7 @@ use base qw( FS::cust_main_Mixin FS::option_Common ); #FS::Record ); use strict; use vars qw( $DEBUG $me $ignore_quantity $conf $ticket_system ); -use Carp; +use Carp qw(cluck); #use Scalar::Util qw( blessed ); use List::Util qw( max ); use FS::Conf; @@ -16,6 +16,7 @@ use FS::domain_record; use FS::part_export; use FS::cdr; use FS::UI::Web; +use FS::export_cust_svc; #most FS::svc_ classes are autoloaded in svc_x emthod use FS::svc_acct; #this one is used in the cache stuff @@ -32,6 +33,15 @@ FS::UID->install_callback( sub { $ticket_system = $conf->config('ticket_system') }); +our $cache_enabled = 0; + +sub _simplecache { + my( $self, $hashref ) = @_; + if ( $cache_enabled && $hashref->{'svc'} ) { + $self->{'_svcpart'} = FS::part_svc->new($hashref); + } +} + sub _cache { my $self = shift; my ( $hashref, $cache ) = @_; @@ -160,6 +170,17 @@ sub delete { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + # delete associated export_cust_svc + foreach my $export_cust_svc ( + qsearch('export_cust_svc',{ 'svcnum' => $self->svcnum }) + ) { + my $error = $export_cust_svc->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my $error = $self->SUPER::delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -629,9 +650,9 @@ L<FS::part_svc>). sub part_svc { my $self = shift; - $self->{'_svcpart'} - ? $self->{'_svcpart'} - : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } ); + return $self->{_svcpart} if $self->{_svcpart}; + cluck 'cust_svc->part_svc called' if $DEBUG; + qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } ); } =item cust_pkg @@ -681,10 +702,10 @@ sub pkg_cancel_date { return $cust_pkg->getfield('cancel') || ''; } -=item label +=item label [ LOCALE ] Returns a list consisting of: -- The name of this service (from part_svc) +- The name of this service (from part_svc), optionally localized - A meaningful identifier (username, domain, or mail alias) - The table name (i.e. svc_domain) for this service - svcnum @@ -693,7 +714,7 @@ Usage example: my($label, $value, $svcdb) = $cust_svc->label; -=item label_long +=item label_long [ LOCALE ] Like the B<label> method, except the second item in the list ("meaningful identifier") may be longer - typically, a full name is included. @@ -706,20 +727,25 @@ sub label_long { shift->_label('svc_label_long', @_); } sub _label { my $self = shift; my $method = shift; + my $locale = shift; my $svc_x = $self->svc_x or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum; - $self->$method($svc_x); + $self->$method($svc_x, undef, undef, $locale); } +# svc_label(_long) takes three arguments: end date, start date, locale +# and FS::svc_*::label methods must accept those also, if they even care + sub svc_label { shift->_svc_label('label', @_); } sub svc_label_long { shift->_svc_label('label_long', @_); } sub _svc_label { my( $self, $method, $svc_x ) = ( shift, shift, shift ); + my ($end, $start, $locale) = @_; ( - $self->part_svc->svc, + $self->part_svc->svc_locale($locale), $svc_x->$method(@_), $self->part_svc->svcdb, $self->svcnum @@ -802,13 +828,12 @@ sub seconds_since { 'internal session db deprecated'; }; =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END -See L<FS::svc_acct/seconds_since_sqlradacct>. Equivalent to -$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless -for records where B<svcdb> is not "svc_acct". +Equivalent to $cust_svc->svc_x->seconds_since_sqlradacct, but +more efficient. Meaningless for records where B<svcdb> is not +svc_acct or svc_broadband. =cut -#note: implementation here, POD in FS::svc_acct sub seconds_since_sqlradacct { my($self, $start, $end) = @_; @@ -947,12 +972,11 @@ sub seconds_since_sqlradacct { =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE See L<FS::svc_acct/attribute_since_sqlradacct>. Equivalent to -$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless -for records where B<svcdb> is not "svc_acct". +$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. +Meaningless for records where B<svcdb> is not svc_acct or svc_broadband. =cut -#note: implementation here, POD in FS::svc_acct #(false laziness w/seconds_since_sqlradacct above) sub attribute_since_sqlradacct { my($self, $start, $end, $attrib) = @_; diff --git a/FS/FS/detail_format.pm b/FS/FS/detail_format.pm index be84680f9..78517dd5b 100644 --- a/FS/FS/detail_format.pm +++ b/FS/FS/detail_format.pm @@ -168,7 +168,7 @@ sub header { my $self = shift; FS::cust_bill_pkg_detail->new( - { 'format' => 'C', 'detail' => $self->mt($self->header_detail) } + { 'format' => 'C', 'detail' => $self->header_detail } ) } @@ -186,6 +186,11 @@ rated_regionname => regionname accountcode => accountcode startdate => startdate +If the formatter is in inbound mode, it will look up a C<cdr_termination> +record and use rated_price and rated_seconds from that, and acctid will be +set to null to avoid linking the CDR to the detail record for the inbound +leg of the call. + 'phonenum' is set to the internal C<phonenum> value set on the formatter object. @@ -209,10 +214,10 @@ sub single_detail { $price = 0 if $cdr->freesidestatus eq 'no-charge'; FS::cust_bill_pkg_detail->new( { - 'acctid' => $cdr->acctid, + 'acctid' => ($self->{inbound} ? '' : $cdr->acctid), 'amount' => $price, 'classnum' => $cdr->rated_classnum, - 'duration' => $cdr->rated_seconds, + 'duration' => $object->rated_seconds, 'regionname' => $cdr->rated_regionname, 'accountcode' => $cdr->accountcode, 'startdate' => $cdr->startdate, @@ -270,10 +275,7 @@ sub time2str_local { $self->{_dh}->time2str(@_); } -sub mt { - my $self = shift; - $self->{_lh}->maketext(@_); -} +# header strings are now localized in FS::TemplateItem_Mixin::detail #imitate previous behavior for now @@ -283,13 +285,11 @@ sub duration { my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr; my $sec = $object->rated_seconds if $object; $sec ||= 0; - # XXX termination objects don't have rated_granularity so this may - # result in inbound CDRs being displayed as min/sec when they shouldn't. - # Should probably fix this. - if ( $cdr->rated_granularity eq '0' ) { + # termination objects now have rated_granularity. + if ( $object->rated_granularity eq '0' ) { '1 call'; } - elsif ( $cdr->rated_granularity eq '60' ) { + elsif ( $object->rated_granularity eq '60' ) { sprintf('%dm', ($sec + 59)/60); } else { 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/export_cust_svc.pm b/FS/FS/export_cust_svc.pm new file mode 100644 index 000000000..7cfdcc6a4 --- /dev/null +++ b/FS/FS/export_cust_svc.pm @@ -0,0 +1,134 @@ +package FS::export_cust_svc; +use base qw(FS::Record); + +use strict; +use FS::Record qw( qsearchs ); + +=head1 NAME + +FS::export_cust_svc - Object methods for export_cust_svc records + +=head1 SYNOPSIS + + use FS::export_cust_svc; + + $record = new FS::export_cust_svc \%hash; + $record = new FS::export_cust_svc { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::export_cust_svc object represents information unique +to a given part_export and cust_svc pair. +FS::export_cust_svc inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item exportcustsvcnum - primary key + +=item exportnum - export (see L<FS::part_export>) + +=item svcnum - service (see L<FS::cust_svc>) + +=item remoteid - id for accessing service on export remote system + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new export_cust_svc object. To add the object to the database, see +L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I<hash> method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'export_cust_svc'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +sub insert { + my $self = shift; + return "export_cust_svc for exportnum ".$self->exportnum. + " svcnum ".$self->svcnum." already exists" + if qsearchs('export_cust_svc',{ 'exportnum' => $self->exportnum, + 'svcnum' => $self->svcnum }); + $self->SUPER::insert; +} + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=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. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid export option. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('exportcustsvcnum') + || $self->ut_foreign_key('exportnum', 'part_export', 'exportnum') + || $self->ut_foreign_key('svcnum', 'cust_svc', 'svcnum') + || $self->ut_text('remoteid') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +Possibly. + +=head1 SEE ALSO + +L<FS::part_export>, L<FS::cust_svc>, L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/h_cust_svc.pm b/FS/FS/h_cust_svc.pm index 7b565adde..89a4cd7d0 100644 --- a/FS/FS/h_cust_svc.pm +++ b/FS/FS/h_cust_svc.pm @@ -39,14 +39,14 @@ sub date_deleted { $self->h_date('delete'); } -=item label END_TIMESTAMP [ START_TIMESTAMP ] +=item label END_TIMESTAMP [ START_TIMESTAMP ] [ LOCALE ] -Returns a label for this historical service, if the service was created before -END_TIMESTAMP and (optionally) not deleted before START_TIMESTAMP. Otherwise, -returns an empty list. +Returns a label for this historical service, if the service was created +before END_TIMESTAMP and (optionally) not deleted before START_TIMESTAMP. +Otherwise, returns an empty list. If a service is found, returns a list consisting of: -- The name of this historical service (from part_svc) +- The name of this historical service (from part_svc), optionally localized - A meaningful identifier (username, domain, or mail alias) - The table name (i.e. svc_domain) for this historical service @@ -55,13 +55,34 @@ If a service is found, returns a list consisting of: sub label { shift->_label('svc_label', @_); } sub label_long { shift->_label('svc_label_long', @_); } +# Parameters to _label: +# +# 1: the cust_svc method we should call to produce the label. (svc_label +# and svc_label_long are defined in FS::cust_svc, not here, and take a svc_x +# object as first argument.) +# 2, 3: date range to use to find the h_svc_x, which will be passed to +# svc_label(_long) and eventually have ->label called on it. +# 4: locale, passed to svc_label(_long) also. +# +# however, if label is called with a locale only, must DTRT (this is a +# FS::cust_svc subclass) + sub _label { my $self = shift; my $method = shift; + my ($end, $start, $locale); + if (defined($_[0])) { + if ( $_[0] =~ /^\d+$/ ) { + ($end, $start, $locale) = @_; + } else { + $locale = shift; + $end = $self->history_date; + } + } #carp "FS::h_cust_svc::_label called on $self" if $DEBUG; warn "FS::h_cust_svc::_label called on $self for $method" if $DEBUG; - my $svc_x = $self->h_svc_x(@_); + my $svc_x = $self->h_svc_x($end, $start); return () unless $svc_x; my $part_svc = $self->part_svc; @@ -71,7 +92,7 @@ sub _label { } my @label; - eval { @label = $self->$method($svc_x, @_); }; + eval { @label = $self->$method($svc_x, $end, $start, $locale); }; if ($@) { carp 'while resolving history record for svcdb/svcnum ' . @@ -85,9 +106,9 @@ sub _label { =item h_svc_x END_TIMESTAMP [ START_TIMESTAMP ] -Returns the FS::h_svc_XXX object for this service as of END_TIMESTAMP (i.e. an -FS::h_svc_acct object or FS::h_svc_domain object, etc.) and (optionally) not -cancelled before START_TIMESTAMP. +Returns the FS::h_svc_XXX object for this service as of END_TIMESTAMP (i.e. +an FS::h_svc_acct object or FS::h_svc_domain object, etc.) and (optionally) +not cancelled before START_TIMESTAMP. =cut diff --git a/FS/FS/h_svc_acct.pm b/FS/FS/h_svc_acct.pm index 6e127a29e..6bc55ebc8 100644 --- a/FS/FS/h_svc_acct.pm +++ b/FS/FS/h_svc_acct.pm @@ -29,6 +29,7 @@ FS::h_svc_acct - Historical account objects sub svc_domain { my $self = shift; local($FS::Record::qsearch_qualify_columns) = 0; + $_[0] ||= $self->history_date; qsearchs( 'h_svc_domain', { 'svcnum' => $self->domsvc }, FS::h_svc_domain->sql_h_searchs(@_), diff --git a/FS/FS/h_svc_forward.pm b/FS/FS/h_svc_forward.pm index 7f6a5cca8..bc24fe911 100644 --- a/FS/FS/h_svc_forward.pm +++ b/FS/FS/h_svc_forward.pm @@ -35,6 +35,7 @@ sub srcsvc_acct { local($FS::Record::qsearch_qualify_columns) = 0; + $_[0] ||= $self->history_date; my $h_svc_acct = qsearchs( 'h_svc_acct', { 'svcnum' => $self->srcsvc }, @@ -58,6 +59,7 @@ sub dstsvc_acct { local($FS::Record::qsearch_qualify_columns) = 0; + $_[0] ||= $self->history_date; my $h_svc_acct = qsearchs( 'h_svc_acct', { 'svcnum' => $self->dstsvc }, diff --git a/FS/FS/h_svc_www.pm b/FS/FS/h_svc_www.pm index e719f1b47..d3f9811a4 100644 --- a/FS/FS/h_svc_www.pm +++ b/FS/FS/h_svc_www.pm @@ -34,6 +34,7 @@ sub domain_record { carp 'Called FS::h_svc_www->domain_record on svcnum ' . $self->svcnum if $DEBUG; local($FS::Record::qsearch_qualify_columns) = 0; + $_[0] ||= $self->history_date; my $domain_record = qsearchs( 'h_domain_record', { 'recnum' => $self->recnum }, diff --git a/FS/FS/log.pm b/FS/FS/log.pm index 95bc4c409..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 @@ -81,15 +83,16 @@ sub insert { my $self = shift; my $error = $self->SUPER::insert; return $error if $error; - my $contexts = {}; #for quick checks when sending emails - foreach ( @_ ) { + my $contexts = {}; # for quick checks when sending emails + my $context_height = @_; # also for email check + foreach ( @_ ) { # ordered from least to most specific my $context = FS::log_context->new({ 'lognum' => $self->lognum, 'context' => $_ }); $error = $context->insert; return $error if $error; - $contexts->{$_} = 1; + $contexts->{$_} = $context_height--; } foreach my $log_email ( qsearch('log_email', @@ -102,8 +105,9 @@ sub insert { } ) ) { - # shouldn't be a lot of these, so not packing this into the qsearch + # shouldn't be a lot of log_email records, so not packing these checks into the qsearch next if $log_email->context && !$contexts->{$log_email->context}; + next if $log_email->context_height && ($contexts->{$log_email->context} > $log_email->context_height); my $msg_template = qsearchs('msg_template',{ 'msgnum' => $log_email->msgnum }); unless ($msg_template) { warn "Could not send email when logging, could not load message template for logemailnum " . $log_email->logemailnum; @@ -113,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, }, @@ -346,9 +350,16 @@ sub search { if ( $params->{'context'} ) { my $quoted = dbh->quote($params->{'context'}); - push @where, - "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ". - "AND log_context.context = $quoted)"; + if ( $params->{'context_height'} =~ /^\d+$/ ) { + my $subq = 'SELECT context FROM log_context WHERE log.lognum = log_context.lognum'. + ' ORDER BY logcontextnum DESC LIMIT '.$params->{'context_height'}; + push @where, + "EXISTS(SELECT 1 FROM ($subq) AS log_context_x WHERE log_context_x.context = $quoted)"; + } else { + push @where, + "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ". + "AND log_context.context = $quoted)"; + } } # agent virtualization @@ -374,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 9dba5824c..37befb515 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -5,11 +5,13 @@ 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 FS::Misc::Geo::standardize_uscensus Cron::bill Cron::backup @@ -21,6 +23,7 @@ my @contexts = ( qw( upgrade_taxable_billpkgnum freeside-paymentech-upload freeside-paymentech-download + test ) ); =head1 NAME diff --git a/FS/FS/log_email.pm b/FS/FS/log_email.pm index 9c53c230a..a055cb4c6 100644 --- a/FS/FS/log_email.pm +++ b/FS/FS/log_email.pm @@ -42,6 +42,9 @@ The following fields are currently supported: =item to_addr - who the email will be sent to (in addition to any bcc on the template) +=item context_height - number of context stack levels to match against +(0 or null matches against full stack, 1 only matches lowest level context, 2 matches lowest two levels, etc.) + =back =head1 METHODS @@ -88,6 +91,7 @@ sub check { || $self->ut_number('min_level') || $self->ut_foreign_key('msgnum', 'msg_template', 'msgnum') || $self->ut_textn('to_addr') + || $self->ut_numbern('context_height') ; return $error if $error; diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index 1dd48cc1a..0a16724a8 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -93,6 +93,7 @@ sub extension_table { ''; } # subclasses don't HAVE to have extensions sub _rebless { my $self = shift; + return '' unless $self->msgclass; my $class = 'FS::msg_template::' . $self->msgclass; eval "use $class;"; bless($self, $class) unless $@; @@ -803,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/msg_template/email.pm b/FS/FS/msg_template/email.pm index 83ff18f19..cc5428bc8 100644 --- a/FS/FS/msg_template/email.pm +++ b/FS/FS/msg_template/email.pm @@ -206,6 +206,12 @@ A string to use as the HTML body; if specified, replaces the entire body of the message. This should be used ONLY by L<FS::report_batch> and may go away in the future. +=item attach + +A L<MIME::Entity> (or arrayref of them) to attach to the message. + +=cut + =back =cut @@ -283,9 +289,20 @@ sub prepare { my @to; if ( exists($opt{'to'}) ) { + @to = split(/\s*,\s*/, $opt{'to'}); + } elsif ( $cust_main ) { - @to = $cust_main->invoicing_list_emailonly; + + my $classnum = $opt{'to_contact_classnum'} || ''; + my @classes = ref($classnum) ? @$classnum : split(',', $classnum); + # traditional behavior: send to all invoice recipients + @classes = ('invoice') unless @classes; + @to = $cust_main->contact_list_email(@classes); + # not guaranteed to produce contacts, but then customers aren't + # guaranteed to have email addresses on file. in that case, env_to + # will be null and sending this message will fail. + } else { die 'no To: address or cust_main object specified'; } @@ -318,13 +335,16 @@ sub prepare { ); warn "$me creating message headers\n" if $DEBUG; + # strip display-name from envelope addresses + # (use Email::Address for this? it chokes on non-ASCII characters in + # the display-name, which is not great for us) my $env_from = $from_addr; - $env_from =~ s/^\s*//; $env_from =~ s/\s*$//; - if ( $env_from =~ /^(.*)\s*<(.*@.*)>$/ ) { - # a common idiom - $env_from = $2; - } - + foreach ($env_from, @to) { + s/^\s*//; + s/\s*$//; + s/^(.*)\s*<(.*@.*)>$/$2/; + } + my $domain; if ( $env_from =~ /\@([\w\.\-]+)/ ) { $domain = $1; @@ -348,13 +368,24 @@ sub prepare { 'Type' => 'multipart/related', ); + if ( $opt{'attach'} ) { + my @attach; + if (ref $opt{'attach'} eq 'ARRAY') { + @attach = @{ $opt{'attach'} }; + } else { + @attach = $opt{'attach'}; + } + foreach (@attach) { + $message->add_part($_); + } + } + #$message->head->replace('Content-type', # 'multipart/related; '. # 'boundary="' . $message->head->multipart_boundary . '"; ' . # 'type=multipart/alternative' #); - - # XXX a facility to attach additional parts is necessary at some point + foreach my $part (@{ $email{mimeparts} }) { warn "$me appending part ".$part->mime_type."\n" if $DEBUG; $message->add_part( $part ); diff --git a/FS/FS/part_event.pm b/FS/FS/part_event.pm index 675177621..58e01271c 100644 --- a/FS/FS/part_event.pm +++ b/FS/FS/part_event.pm @@ -372,7 +372,7 @@ sub eventtable_labels { 'cust_pay' => 'Payment', 'cust_pay_batch' => 'Batch payment', 'cust_statement' => 'Statement', #too general a name here? "Invoice group"? - 'svc_acct' => 'Login service', + 'svc_acct' => 'Account service (svc_acct)', ; \%hash 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/part_event/Action/cust_bill_email.pm b/FS/FS/part_event/Action/cust_bill_email.pm index 3331a4cb6..80bcaa1a7 100644 --- a/FS/FS/part_event/Action/cust_bill_email.pm +++ b/FS/FS/part_event/Action/cust_bill_email.pm @@ -20,12 +20,18 @@ sub option_fields { sub default_weight { 51; } sub do_action { - my( $self, $cust_bill ) = @_; + my( $self, $cust_bill, $cust_event ) = @_; my $cust_main = $cust_bill->cust_main; $cust_bill->set('mode' => $self->option('modenum')); - $cust_bill->email unless $cust_main->invoice_noemail; + if ( $cust_main->invoice_noemail ) { + # what about if the customer has no email dest? + $cust_event->set('no_action', 'Y'); + return "customer has invoice_noemail flag"; + } else { + $cust_bill->email; + } } 1; diff --git a/FS/FS/part_event/Action/cust_bill_fsinc_print.pm b/FS/FS/part_event/Action/cust_bill_fsinc_print.pm new file mode 100644 index 000000000..e1e25bf26 --- /dev/null +++ b/FS/FS/part_event/Action/cust_bill_fsinc_print.pm @@ -0,0 +1,32 @@ +package FS::part_event::Action::cust_bill_fsinc_print; + +use strict; +use base qw( FS::part_event::Action ); + +sub description { 'Send invoice to Freeside Inc. for printing and mailing'; } + +sub eventtable_hashref { + { 'cust_bill' => 1 }; +} + +sub option_fields { + ( + 'modenum' => { label => 'Invoice mode', + type => 'select-invoice_mode', + }, + ); +} + +sub default_weight { 52; } + +sub do_action { + my( $self, $cust_bill ) = @_; + + $cust_bill->set('mode' => $self->option('modenum')); + + my $letter_id = $cust_bill->postal_mail_fsinc; + + #TODO: store this so we can query for a status later +} + +1; diff --git a/FS/FS/part_event/Action/cust_bill_print.pm b/FS/FS/part_event/Action/cust_bill_print.pm index b94e882ff..e6a27a34e 100644 --- a/FS/FS/part_event/Action/cust_bill_print.pm +++ b/FS/FS/part_event/Action/cust_bill_print.pm @@ -24,14 +24,22 @@ sub option_fields { sub default_weight { 51; } sub do_action { - my( $self, $cust_bill ) = @_; + my( $self, $cust_bill, $cust_event ) = @_; #my $cust_main = $self->cust_main($cust_bill); my $cust_main = $cust_bill->cust_main; $cust_bill->set('mode' => $self->option('modenum')); - $cust_bill->print unless $self->option('skip_nopost') - && ! grep { $_ eq 'POST' } $cust_main->invoicing_list; + if ( $self->option('skip_nopost') + && ! grep { $_ eq 'POST' } $cust_main->invoicing_list + ) { + # then skip customers + $cust_event->set('no_action', 'Y'); + return "customer doesn't receive postal invoices"; # as statustext + + } else { + $cust_bill->print; + } } 1; diff --git a/FS/FS/part_event/Action/cust_bill_send_with_notice.pm b/FS/FS/part_event/Action/cust_bill_send_with_notice.pm new file mode 100644 index 000000000..d2678fd82 --- /dev/null +++ b/FS/FS/part_event/Action/cust_bill_send_with_notice.pm @@ -0,0 +1,48 @@ +package FS::part_event::Action::cust_bill_send_with_notice; + +use strict; +use base qw( FS::part_event::Action ); +use FS::msg_template; +use MIME::Entity; + +sub description { 'Email a notice to the customer with invoice attached'; } + +sub eventtable_hashref { + { 'cust_bill' => 1 }; +} + +sub option_fields { + ( + 'msgnum' => { label => 'Message template', + type => 'select-table', + table => 'msg_template', + hashref => { disabled => '' }, + name_col => 'msgname', + disable_empty => 1, + }, + 'modenum' => { label => 'Invoice mode', + type => 'select-invoice_mode', + }, + + ); +} + +sub default_weight { 56; } + +sub do_action { + my( $self, $cust_bill, $cust_event ) = @_; + + $cust_bill->set('mode' => $self->option('modenum')); + my %args = ( 'time' => $cust_event->_date ); + my $mimepart = MIME::Entity->build( $cust_bill->mimebuild_pdf(\%args) ); + my $msgnum = $self->option('msgnum'); + my $msg_template = FS::msg_template->by_key($msgnum) + or die "can't find message template #$msgnum to send with invoice"; + $msg_template->send( + 'cust_main' => $cust_bill->cust_main, + 'object' => $cust_bill, + 'attach' => $mimepart + ); +} + +1; diff --git a/FS/FS/part_event/Action/http.pm b/FS/FS/part_event/Action/http.pm index 673cd4356..72a345d6b 100644 --- a/FS/FS/part_event/Action/http.pm +++ b/FS/FS/part_event/Action/http.pm @@ -4,6 +4,7 @@ use base qw( FS::part_event::Action ); use strict; use vars qw( $me ); use Data::Dumper; +use IO::Socket::SSL; use LWP::UserAgent; use HTTP::Request::Common; use Cpanel::JSON::XS; @@ -68,10 +69,17 @@ sub do_action { ( $field, $value ); } split(/\n/, $self->option('content') ); + if ( $self->option('debug') ) { + warn "[$me] $_: ". $content{$_}. "\n" foreach keys %content; + } + my $content = encode_json( \%content ); my @lwp_opts = (); - push @lwp_opts, 'ssl_opts'=>{ 'verify_hostname'=>0 } + push @lwp_opts, 'ssl_opts' => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + } if $self->option('ssl_no_verify'); my $ua = LWP::UserAgent->new(@lwp_opts); @@ -82,7 +90,7 @@ sub do_action { ); if ( $self->option('debug') ) { - + #XXX dump raw request for debugging } my $response = $ua->request($req); diff --git a/FS/FS/part_event/Action/letter.pm b/FS/FS/part_event/Action/letter.pm index 835dec2b9..123b99004 100644 --- a/FS/FS/part_event/Action/letter.pm +++ b/FS/FS/part_event/Action/letter.pm @@ -26,7 +26,7 @@ sub option_fields { ); } -sub default_weight { 56; } #? +sub default_weight { 58; } sub do_action { my( $self, $object ) = @_; diff --git a/FS/FS/part_event/Action/rt_ticket.pm b/FS/FS/part_event/Action/rt_ticket.pm new file mode 100644 index 000000000..a6a616033 --- /dev/null +++ b/FS/FS/part_event/Action/rt_ticket.pm @@ -0,0 +1,100 @@ +package FS::part_event::Action::rt_ticket; + +use strict; +use base qw( FS::part_event::Action ); +use FS::Record qw( qsearchs ); +use FS::msg_template; + +sub description { 'Open an RT ticket for the customer' } + +#need to be valid for msg_template substitution +sub eventtable_hashref { + { 'cust_main' => 1, + 'cust_bill' => 1, + 'cust_pkg' => 1, + 'cust_pay' => 1, + 'svc_acct' => 1, + }; +} + +sub option_fields { + ( + 'msgnum' => { 'label' => 'Template', + 'type' => 'select-table', + 'table' => 'msg_template', + 'name_col' => 'msgname', + 'hashref' => { disabled => '' }, + 'disable_empty' => 1, + }, + 'queueid' => { 'label' => 'Queue', + 'type' => 'select-rt-queue', + }, + 'requestor' => { 'label' => 'Requestor', + 'type' => 'select', + 'options' => [ 0, 1 ], + 'labels' => { + 0 => 'Customer\'s invoice address', + 1 => 'Template From: address', + }, + }, + + ); +} + +sub default_weight { 59; } + +sub do_action { + + my( $self, $object ) = @_; + + my $cust_main = $self->cust_main($object) + or die "Could not load cust_main"; + + my $msgnum = $self->option('msgnum'); + my $msg_template = qsearchs('msg_template', { 'msgnum' => $msgnum } ) + or die "Template $msgnum not found"; + + my $queueid = $self->option('queueid') + or die "No queue specified"; + + # technically this only works if create_ticket is implemented, + # and it is only implemented in RT_Internal, + # but we can let create_ticket throw that error + my $conf = new FS::Conf; + die "rt_ticket event - no ticket system configured" + unless $conf->config('ticket_system'); + + FS::TicketSystem->init(); + + my $cust_msg = $msg_template->prepare( + 'cust_main' => $cust_main, + 'object' => $object, + ); + + my $subject = $cust_msg->entity->head->get('Subject'); + chomp($subject); + + my $requestor = $self->option('requestor') + ? $msg_template->from_addr + : [ $cust_main->invoicing_list_emailonly ]; + + my $svcnum = ref($object) eq 'FS::svc_acct' + ? $object->svcnum + : undef; + + my $err_or_ticket = FS::TicketSystem->create_ticket( + '', #session should already exist + 'queue' => $queueid, + 'subject' => $subject, + 'requestor' => $requestor, + 'message' => $cust_msg->preview, + 'mime_type' => 'text/html', + 'custnum' => $cust_main->custnum, + 'svcnum' => $svcnum, + ); + die $err_or_ticket unless ref($err_or_ticket); + return ''; + +} + +1; diff --git a/FS/FS/part_event/Condition.pm b/FS/FS/part_event/Condition.pm index 36fbe9a0d..d1d519683 100644 --- a/FS/FS/part_event/Condition.pm +++ b/FS/FS/part_event/Condition.pm @@ -312,7 +312,7 @@ sub option_age_from { } elsif ( $age =~ /^(\d+)d$/i ) { $mday -= $1; } elsif ( $age =~ /^(\d+)h$/i ) { - $hour -= $hour; + $hour -= $1; } else { die "unparsable age: $age"; } diff --git a/FS/FS/part_event/Condition/day_of_week.pm b/FS/FS/part_event/Condition/day_of_week.pm new file mode 100644 index 000000000..6b8431097 --- /dev/null +++ b/FS/FS/part_event/Condition/day_of_week.pm @@ -0,0 +1,49 @@ +package FS::part_event::Condition::day_of_week; + +use strict; +use base qw( FS::part_event::Condition ); +use FS::Record qw( dbh ); + +tie my %dayofweek, 'Tie::IxHash', + 0 => 'Sunday', + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', +; + +sub description { + "Run only on certain days of the week", +} + +sub option_fields { + ( + 'dayofweek' => { + label => 'Days to run', + type => 'checkbox-multiple', + options => [ values %dayofweek ], + option_labels => { map { $_ => $_ } values %dayofweek }, + }, + ); +} + +sub condition { # is this even necessary? condition_sql is exact. + my( $self, $object, %opt ) = @_; + + my $today = $dayofweek{(localtime($opt{'time'}))[6]}; + if (grep { $_ eq $today } (keys %{$self->option('dayofweek')})) { + return 1; + } + ''; +} + +sub condition_sql { + my( $class, $table, %opt ) = @_; + my $today = $dayofweek{(localtime($opt{'time'}))[6]}; + my $day = $class->condition_sql_option_option('dayofweek'); + return dbh->quote($today) . " IN $day"; +} + +1; diff --git a/FS/FS/part_event/Condition/has_cust_payby_auto.pm b/FS/FS/part_event/Condition/has_cust_payby_auto.pm index 9f914292f..f13b639ab 100644 --- a/FS/FS/part_event/Condition/has_cust_payby_auto.pm +++ b/FS/FS/part_event/Condition/has_cust_payby_auto.pm @@ -4,7 +4,7 @@ use base qw( FS::part_event::Condition ); use strict; use Tie::IxHash; use FS::payby; -use FS::Record qw(qsearch); +use FS::Record qw( qsearch dbh ); sub description { 'Customer has automatic payment information'; @@ -30,11 +30,24 @@ sub condition { my $cust_main = $self->cust_main($object); + #handle multiple (HASH) type options migrated from a v3 payby.pm condition + # (and maybe we should be a select-multiple or checkbox-multiple too?) + my @payby = (); + my $payby = $self->option('payby'); + if ( ref($payby) ) { + @payby = keys %$payby; + } elsif ( $payby ) { + @payby = ( $payby ); + } + scalar( qsearch({ 'table' => 'cust_payby', 'hashref' => { 'custnum' => $cust_main->custnum, - 'payby' => $self->option('payby') + #'payby' => $self->option('payby') }, + 'extra_sql' => 'AND payby IN ( '. + join(',', map dbh->quote($_), @payby). + ' ) ', 'order_by' => 'LIMIT 1', }) ); diff --git a/FS/FS/part_event/Condition/has_pkg_class_cancelled.pm b/FS/FS/part_event/Condition/has_pkg_class_cancelled.pm new file mode 100644 index 000000000..d6e25a4f7 --- /dev/null +++ b/FS/FS/part_event/Condition/has_pkg_class_cancelled.pm @@ -0,0 +1,43 @@ +package FS::part_event::Condition::has_pkg_class_cancelled; +use base qw( FS::part_event::Condition ); + +use strict; + +sub description { + 'Customer has canceled package with class'; +} + +sub eventtable_hashref { + { 'cust_main' => 1, + 'cust_bill' => 1, + 'cust_pkg' => 1, + }; +} + +#something like this +sub option_fields { + ( + 'pkgclass' => { 'label' => 'Package Class', + 'type' => 'select-pkg_class', + 'multiple' => 1, + }, + 'age' => { 'label' => 'Cacnellation in last', + 'type' => 'freq', + }, + ); +} + +sub condition { + my( $self, $object, %opt ) = @_; + + my $cust_main = $self->cust_main($object); + + my $age = $self->option_age_from('age', $opt{'time'} ); + + #XXX test + my $hashref = $self->option('pkgclass') || {}; + grep { $hashref->{ $_->part_pkg->classnum } && $_->get('cancel') > $age } + $cust_main->cancelled_pkgs; +} + +1; diff --git a/FS/FS/part_event/Condition/has_pkgpart_cancelled.pm b/FS/FS/part_event/Condition/has_pkgpart_cancelled.pm new file mode 100644 index 000000000..7e2a5671c --- /dev/null +++ b/FS/FS/part_event/Condition/has_pkgpart_cancelled.pm @@ -0,0 +1,45 @@ +package FS::part_event::Condition::has_pkgpart_cancelled; +use base qw( FS::part_event::Condition ); + +use strict; + +sub description { 'Customer has canceled specific package(s)'; } + +sub eventtable_hashref { + { 'cust_main' => 1, + 'cust_bill' => 1, + 'cust_pkg' => 1, + }; +} + +sub option_fields { + ( + 'if_pkgpart' => { 'label' => 'Only packages: ', + 'type' => 'select-part_pkg', + 'multiple' => 1, + }, + 'age' => { 'label' => 'Cancellation in last', + 'type' => 'freq', + }, + ); +} + +sub condition { + my( $self, $object, %opt ) = @_; + + my $cust_main = $self->cust_main($object); + + my $age = $self->option_age_from('age', $opt{'time'} ); + + my $if_pkgpart = $self->option('if_pkgpart') || {}; + grep { $if_pkgpart->{ $_->pkgpart } && $_->get('cancel') > $age } + $cust_main->cancelled_pkgs; + +} + +#XXX +#sub condition_sql { +# +#} + +1; diff --git a/FS/FS/part_event/Condition/hasnt_pkg_class_cancelled.pm b/FS/FS/part_event/Condition/hasnt_pkg_class_cancelled.pm new file mode 100644 index 000000000..d54fb88fa --- /dev/null +++ b/FS/FS/part_event/Condition/hasnt_pkg_class_cancelled.pm @@ -0,0 +1,52 @@ +package FS::part_event::Condition::hasnt_pkg_class_cancelled; +use base qw( FS::part_event::Condition ); + +use strict; + +sub description { + 'Customer does not have canceled package with class'; +} + +sub eventtable_hashref { + { 'cust_main' => 1, + 'cust_bill' => 1, + 'cust_pkg' => 1, + }; +} + +#something like this +sub option_fields { + ( + 'pkgclass' => { 'label' => 'Package Class', + 'type' => 'select-pkg_class', + 'multiple' => 1, + }, + 'age_newest' => { 'label' => 'Cancelled more than', + 'type' => 'freq', + 'post_text' => ' ago (blank for no limit)', + 'allow_blank' => 1, + }, + 'age' => { 'label' => 'Cancelled less than', + 'type' => 'freq', + 'post_text' => ' ago (blank for no limit)', + 'allow_blank' => 1, + }, + ); +} + +sub condition { + my( $self, $object, %opt ) = @_; + + my $cust_main = $self->cust_main($object); + + my $oldest = length($self->option('age')) ? $self->option_age_from('age', $opt{'time'} ) : 0; + my $newest = $self->option_age_from('age_newest', $opt{'time'} ); + + my $pkgclass = $self->option('pkgclass') || {}; + + ! grep { $pkgclass->{ $_->part_pkg->classnum } && ($_->get('cancel') > $oldest) && ($_->get('cancel') <= $newest) } + $cust_main->cancelled_pkgs; +} + +1; + diff --git a/FS/FS/part_event/Condition/hasnt_pkgpart_cancelled.pm b/FS/FS/part_event/Condition/hasnt_pkgpart_cancelled.pm new file mode 100644 index 000000000..42845cb8a --- /dev/null +++ b/FS/FS/part_event/Condition/hasnt_pkgpart_cancelled.pm @@ -0,0 +1,55 @@ +package FS::part_event::Condition::hasnt_pkgpart_cancelled; +use base qw( FS::part_event::Condition ); + +use strict; + +sub description { 'Customer does not have canceled specific package(s)'; } + +sub eventtable_hashref { + { 'cust_main' => 1, + 'cust_bill' => 1, + 'cust_pkg' => 1, + }; +} + +sub option_fields { + ( + 'if_pkgpart' => { 'label' => 'Packages: ', + 'type' => 'select-part_pkg', + 'multiple' => 1, + }, + 'age_newest' => { 'label' => 'Cancelled more than', + 'type' => 'freq', + 'post_text' => ' ago (blank for no limit)', + 'allow_blank' => 1, + }, + 'age' => { 'label' => 'Cancelled less than', + 'type' => 'freq', + 'post_text' => ' ago (blank for no limit)', + 'allow_blank' => 1, + }, + ); +} + +sub condition { + my( $self, $object, %opt ) = @_; + + my $cust_main = $self->cust_main($object); + + my $oldest = length($self->option('age')) ? $self->option_age_from('age', $opt{'time'} ) : 0; + my $newest = $self->option_age_from('age_newest', $opt{'time'} ); + + my $if_pkgpart = $self->option('if_pkgpart') || {}; + + ! grep { $if_pkgpart->{ $_->pkgpart } && ($_->get('cancel') > $oldest) && ($_->get('cancel') <= $newest) } + $cust_main->cancelled_pkgs; + +} + +#XXX +#sub condition_sql { +# +#} + +1; + diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm index 182f47608..572a1b684 100644 --- a/FS/FS/part_export.pm +++ b/FS/FS/part_export.pm @@ -10,6 +10,7 @@ use FS::part_svc; use FS::part_export_option; use FS::part_export_machine; use FS::svc_export_machine; +use FS::export_cust_svc; #for export modules, though they should probably just use it themselves use FS::queue; @@ -162,6 +163,17 @@ sub delete { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + # delete associated export_cust_svc + foreach my $export_cust_svc ( + qsearch('export_cust_svc',{ 'exportnum' => $self->exportnum }) + ) { + my $error = $export_cust_svc->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + # clean up export_nas records my $error = $self->process_m2m( 'link_table' => 'export_nas', @@ -637,6 +649,81 @@ sub _export_unsuspend { $self->_export_replace( $svc_x, $old ); } +=item get_remoteid SVC + +Returns the remote id for this export for the given service. + +=cut + +sub get_remoteid { + my ($self, $svc_x) = @_; + + my $export_cust_svc = qsearchs('export_cust_svc',{ + 'exportnum' => $self->exportnum, + 'svcnum' => $svc_x->svcnum + }); + + return $export_cust_svc ? $export_cust_svc->remoteid : ''; +} + +=item set_remoteid SVC VALUE + +Sets the remote id for this export for the given service. +See L<FS::export_cust_svc>. + +If value is true, inserts or updates export_cust_svc record. +If value is false, deletes any existing record. + +Returns error message, blank on success. + +=cut + +sub set_remoteid { + my ($self, $svc_x, $value) = @_; + + my $export_cust_svc = qsearchs('export_cust_svc',{ + 'exportnum' => $self->exportnum, + 'svcnum' => $svc_x->svcnum + }); + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = ''; + if ($value) { + if ($export_cust_svc) { + $export_cust_svc->set('remoteid',$value); + $error = $export_cust_svc->replace; + } else { + $export_cust_svc = new FS::export_cust_svc { + 'exportnum' => $self->exportnum, + 'svcnum' => $svc_x->svcnum, + 'remoteid' => $value + }; + $error = $export_cust_svc->insert; + } + } else { + if ($export_cust_svc) { + $error = $export_cust_svc->delete; + } #otherwise, it already doesn't exist + } + + if ($oldAutoCommit) { + $dbh->rollback if $error; + $dbh->commit unless $error; + } + + return $error; +} + =item export_links SVC_OBJECT ARRAYREF Adds a list of web elements to ARRAYREF specific to this export and SVC_OBJECT. @@ -717,6 +804,8 @@ will return an array of actual DID numbers. Passing 'tollfree' with a true value will override the whole hierarchy and return an array of tollfree numbers. +C<get_dids> methods should report errors via die(). + =cut # no stub; can('get_dids') should return false by default diff --git a/FS/FS/part_export/bandwidth_com.pm b/FS/FS/part_export/bandwidth_com.pm index 6c69fe356..6d868e640 100644 --- a/FS/FS/part_export/bandwidth_com.pm +++ b/FS/FS/part_export/bandwidth_com.pm @@ -56,6 +56,16 @@ with this IP address exists, one will be created.</P> <P>If you are operating a central SIP gateway to receive traffic for all (or a subset of) customers, you should configure a phone service with a fixed value, or a list of fixed values, for the sip_server field.</P> +<P>To find your account ID and site ID: + <UL> + <LI>Login to <a target="_blank" href="https://dashboard.bandwidth.com">the Dashboard. + </a></LI> + <LI>Under "Your subaccounts", find the subaccount (site) that you want to use + for exported DIDs. Click the "manage sub-account" link.</LI> + <LI>Look at the URL. It will end in <i>{"a":xxxxxxx,"s":yyyy}</i>.</LI> + <LI>Your account ID is <i>xxxxxxx</i>, and the site ID is <i>yyyy</i>.</LI> + </UL> +</P> END ); @@ -151,43 +161,46 @@ sub can_get_dids { 1 } sub get_dids_npa_select { 1 } sub get_dids { - local $SIG{__DIE__}; my $self = shift; my %opt = @_; my ($exportnum) = $self->exportnum =~ /^(\d+)$/; - return [] if $opt{'tollfree'}; # we'll come back to this + try { + return [] if $opt{'tollfree'}; # we'll come back to this - my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'}; + my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'}; - if ( $nxx ) { + if ( $nxx ) { - die "areacode required\n" unless $npa; - my $limit = $self->option('num_dids') || 20; - my $result = $self->api_get('availableNumbers', [ - 'npaNxx' => $npa.$nxx, - 'quantity' => $limit, - 'LCA' => 'false', - # find only those that match the NPA-NXX, not those thought to be in - # the same local calling area. though that might be useful. - ]); - return [ $result->findnodes('//TelephoneNumber')->to_literal_list ]; + die "areacode required\n" unless $npa; + my $limit = $self->option('num_dids') || 20; + my $result = $self->api_get('availableNumbers', [ + 'npaNxx' => $npa.$nxx, + 'quantity' => $limit, + 'LCA' => 'false', + # find only those that match the NPA-NXX, not those thought to be in + # the same local calling area. though that might be useful. + ]); + return [ $result->findnodes('//TelephoneNumber')->to_literal_list ]; - } elsif ( $npa ) { + } elsif ( $npa ) { - return $self->npanxx_cache($npa); + return $self->npanxx_cache($npa); - } elsif ( $state ) { + } elsif ( $state ) { - return $self->npa_cache($state); + return $self->npa_cache($state); - } else { # something's wrong + } else { # something's wrong - warn "get_dids called with no arguments"; - return []; + warn "get_dids called with no arguments"; + return []; + } + } catch { + die "$me $_\n"; } } diff --git a/FS/FS/part_export/cust_http.pm b/FS/FS/part_export/cust_http.pm index f72d00698..c13e18db1 100644 --- a/FS/FS/part_export/cust_http.pm +++ b/FS/FS/part_export/cust_http.pm @@ -55,7 +55,7 @@ tie %options, 'Tie::IxHash', ; %info = ( - 'svc' => [qw( cust_main cust_location )], + 'svc' => [qw( cust_main )], 'desc' => 'Send an HTTP or HTTPS GET or POST request, for customers.', 'options' => \%options, 'no_machine' => 1, diff --git a/FS/FS/part_export/cust_location_http.pm b/FS/FS/part_export/cust_location_http.pm new file mode 100644 index 000000000..fe7722340 --- /dev/null +++ b/FS/FS/part_export/cust_location_http.pm @@ -0,0 +1,196 @@ +package FS::part_export::cust_location_http; + +use strict; +use base qw( FS::part_export::http ); +use vars qw( %options %info ); + +my @location_fields = qw( + custnum + prospectnum + locationname + address1 + address2 + city + county + state + zip + country + latitude + longitude + censustract + censusyear + district + geocode + location_type + location_number + location_kind + incorporated +); + +tie %options, 'Tie::IxHash', + 'method' => { label =>'Method', + type =>'select', + #options =>[qw(POST GET)], + options =>[qw(POST)], + default =>'POST' }, + 'location_url' => { label => 'Location URL' }, + 'package_url' => { label => 'Package URL' }, + 'ssl_no_verify' => { label => 'Skip SSL certificate validation', + type => 'checkbox', + }, + 'include_fields' => { 'label' => 'Include fields', + 'type' => 'select', + 'multiple' => 1, + 'options' => [ @location_fields ] }, + 'location_data' => { 'label' => 'Location data', + 'type' => 'textarea' }, + 'package_data' => { 'label' => 'Package data', + 'type' => 'textarea' }, + 'success_regexp' => { + label => 'Success Regexp', + default => '', + }, +; + +%info = ( + 'svc' => [qw( cust_location )], + 'desc' => 'Send an HTTP or HTTPS GET or POST request, for customer locations', + 'options' => \%options, + 'no_machine' => 1, + 'notes' => <<'END', +Send an HTTP or HTTPS GET or POST to the specified URLs on customer location +creation/update (action 'location') and package location assignment/change (action 'package'). +Leave a URL blank to skip that action. +Always sends locationnum, action, and fields specified in the export options. +Action 'package' also sends pkgnum and change_pkgnum (the previous pkgnum, +because location changes usually instigate a pkgnum change.) +Simple field values can be selected in 'Include fields', and more complex +values can be specified in the data field options as perl code using vars +$cust_location, $cust_main and (where relevant) $cust_pkg. +Action 'location' only sends on update if a specified field changed. +Note that scheduled future package changes are currently sent when the change is scheduled +(this may not be the case in future versions of this export.) +For HTTPS support, <a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a> +or <a href="http://search.cpan.org/dist/IO-Socket-SSL">IO::Socket::SSL</a> is required. +END +); + +# we don't do anything on deletion because we generally don't delete locations +# +# we don't send blank custnum/prospectnum because we do a lot of inserting/replacing +# with blank values and then immediately overwriting, but that unfortunately +# makes it difficult to indicate if this is the first time we've sent the location +# to the customer--hence we don't distinguish insert from update in the cgi vars + +# gets invoked by FS::part_export::http _export_insert +sub _export_command { + my( $self, $action, $cust_location ) = @_; + + # redundant--cust_location exports don't get invoked by cust_location->delete, + # or by any status trigger, but just to be clear, since http export has other actions... + return '' unless $action eq 'insert'; + + $self->_http_queue_standard( + 'action' => 'location', + (map { $_ => $cust_location->get($_) } ('locationnum', $self->_include_fields)), + $self->_eval_replace('location_data',$cust_location,$cust_location->cust_main), + ); + +} + +sub _export_replace { + my( $self, $new, $old ) = @_; + + my $changed = 0; + + # even if they don't want custnum/prospectnum exported, + # inserts that lack custnum/prospectnum don't trigger exports, + # so we might not have previously reported these + $changed = 1 if $new->custnum && !$old->custnum; + $changed = 1 if $new->prospectnum && !$old->prospectnum; + + foreach my $field ($self->_include_fields) { + last if $changed; + next if $new->get($field) eq $old->get($field); + next if ($field =~ /latitude|longitude/) and $new->get($field) == $old->get($field); + $changed = 1; + } + + my %old_eval; + unless ($changed) { + %old_eval = $self->_eval_replace('location_data', $old, $old->cust_main), + } + + my %eval = $self->_eval_replace('location_data', $new, $new->cust_main); + + foreach my $key (keys %eval) { + last if $changed; + next if $eval{$key} eq $old_eval{$key}; + $changed = 1; + } + + return '' unless $changed; + + $self->_http_queue_standard( + 'action' => 'location', + (map { $_ => $new->get($_) } ('locationnum', $self->_include_fields)), + %eval, + ); +} + +# not to be confused with export_pkg_change, which is for svcs +sub export_pkg_location { + my ($self, $cust_pkg) = @_; + + return '' unless $cust_pkg->locationnum; + + my $cust_location = $cust_pkg->cust_location; + + $self->_http_queue_standard( + 'action' => 'package', + (map { $_ => $cust_pkg->get($_) } ('pkgnum', 'change_pkgnum', 'locationnum')), + (map { $_ => $cust_location->get($_) } $self->_include_fields), + $self->_eval_replace('package_data',$cust_location,$cust_pkg->cust_main,$cust_pkg), + ); +} + +sub _http_queue_standard { + my $self = shift; + my %opts = @_; + my $url; + if ($opts{'action'} eq 'location') { + $url = $self->option('location_url'); + return '' unless $url; + } elsif ($opts{'action'} eq 'package') { + $url = $self->option('package_url'); + return '' unless $url; + } else { + return "Bad action ".$opts{'action'}; + } + $self->http_queue( '', + ( $self->option('ssl_no_verify') ? 'ssl_no_verify' : '' ), + $self->option('method'), + $url, + $self->option('success_regexp'), + %opts + ); +} + +sub _include_fields { + my $self = shift; + split( /\s+/, $self->option('include_fields') ); +} + +sub _eval_replace { + my ($self,$option,$cust_location,$cust_main,$cust_pkg) = @_; + return + map { + /^\s*(\S+)\s+(.*)$/ or /()()/; + my( $field, $value_expression ) = ( $1, $2 ); + my $value = eval $value_expression; + die $@ if $@; + ( $field, $value ); + } split(/\n/, $self->option($option) ); +} + +1; diff --git a/FS/FS/part_export/http.pm b/FS/FS/part_export/http.pm index 6cac60058..42a35cb07 100644 --- a/FS/FS/part_export/http.pm +++ b/FS/FS/part_export/http.pm @@ -3,6 +3,9 @@ package FS::part_export::http; use base qw( FS::part_export ); use vars qw( %options %info ); use Tie::IxHash; +use LWP::UserAgent; +use HTTP::Request::Common qw( POST ); +use IO::Socket::SSL; tie %options, 'Tie::IxHash', 'method' => { label =>'Method', @@ -149,13 +152,12 @@ sub http { $method = lc($method); - eval "use LWP::UserAgent;"; - die "using LWP::UserAgent: $@" if $@; - eval "use HTTP::Request::Common;"; - die "using HTTP::Request::Common: $@" if $@; - my @lwp_opts = (); - push @lwp_opts, 'ssl_opts'=>{ 'verify_hostname'=>0 } if $ssl_no_verify; + push @lwp_opts, 'ssl_opts' => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + } + if $ssl_no_verify; my $ua = LWP::UserAgent->new(@lwp_opts); #my $response = $ua->$method( diff --git a/FS/FS/part_export/ispconfig3.pm b/FS/FS/part_export/ispconfig3.pm new file mode 100644 index 000000000..9d22d1995 --- /dev/null +++ b/FS/FS/part_export/ispconfig3.pm @@ -0,0 +1,375 @@ +package FS::part_export::ispconfig3; + +use strict; + +use base qw( FS::part_export ); + +use Data::Dumper; +use SOAP::Lite; +use IO::Socket::SSL; + +=pod + +=head1 NAME + +FS::part_export::ispconfig3 + +=head1 SYNOPSIS + +ISPConfig 3 integration for Freeside + +=head1 DESCRIPTION + +This export offers basic svc_acct provisioning for ISPConfig 3. +All email accounts will be assigned to a single specified client. + +This module also provides generic methods for working through the L</ISPConfig3 API>. + +=cut + +use vars qw( %info ); + +my @yesno = ( + options => ['y','n'], + option_labels => { 'y' => 'yes', 'n' => 'no' }, +); + +tie my %options, 'Tie::IxHash', + 'soap_location' => { label => 'SOAP Location' }, + 'username' => { label => 'User Name', + default => '' }, + 'password' => { label => 'Password', + default => '' }, + 'debug' => { type => 'checkbox', + label => 'Enable debug warnings' }, + 'subheading' => { type => 'title', + label => 'Account defaults' }, + 'client_id' => { label => 'Client ID' }, + 'server_id' => { label => 'Server ID' }, + 'maildir' => { label => 'Maildir (substitutions from svc_acct, e.g. /mail/$domain/$username)', }, + 'cc' => { label => 'Cc' }, + 'autoresponder_text' => { label => 'Autoresponder text', + default => 'Out of Office Reply' }, + 'move_junk' => { type => 'select', + options => ['y','n'], + option_labels => { 'y' => 'yes', 'n' => 'no' }, + label => 'Move junk' }, + 'postfix' => { type => 'select', + @yesno, + label => 'Postfix' }, + 'access' => { type => 'select', + @yesno, + label => 'Access' }, + 'disableimap' => { type => 'select', + @yesno, + label => 'Disable IMAP' }, + 'disablepop3' => { type => 'select', + @yesno, + label => 'Disable POP3' }, + 'disabledeliver' => { type => 'select', + @yesno, + label => 'Disable deliver' }, + 'disablesmtp' => { type => 'select', + @yesno, + label => 'Disable SMTP' }, +; + +%info = ( + 'svc' => 'svc_acct', + 'desc' => 'Export email account to ISPConfig 3', + 'options' => \%options, + 'no_machine' => 1, + 'notes' => <<'END', +All email accounts will be assigned to a single specified client and server. +END +); + +sub _mail_user_params { + my ($self, $svc_acct) = @_; + # all available api fields are in comments below, even if we don't use them + return { + #server_id (int(11)) + 'server_id' => $self->option('server_id'), + #email (varchar(255)) + 'email' => $svc_acct->username.'@'.$svc_acct->domain, + #login (varchar(255)) + 'login' => $svc_acct->username.'@'.$svc_acct->domain, + #password (varchar(255)) + 'password' => $svc_acct->_password, + #name (varchar(255)) + 'name' => $svc_acct->finger, + #uid (int(11)) + 'uid' => $svc_acct->uid, + #gid (int(11)) + 'gid' => $svc_acct->gid, + #maildir (varchar(255)) + 'maildir' => $self->_substitute($self->option('maildir'),$svc_acct), + #quota (bigint(20)) + 'quota' => $svc_acct->quota, + #cc (varchar(255)) + 'cc' => $self->option('cc'), + #homedir (varchar(255)) + 'homedir' => $svc_acct->dir, + + ## initializing with autoresponder off, but this could become an export option... + #autoresponder (enum('n','y')) + 'autoresponder' => 'n', + #autoresponder_start_date (datetime) + #autoresponder_end_date (datetime) + #autoresponder_text (mediumtext) + 'autoresponder_text' => $self->option('autoresponder_text'), + + #move_junk (enum('n','y')) + 'move_junk' => $self->option('move_junk'), + #postfix (enum('n','y')) + 'postfix' => $self->option('postfix'), + #access (enum('n','y')) + 'access' => $self->option('access'), + + ## not needed right now, not sure what it is + #custom_mailfilter (mediumtext) + + #disableimap (enum('n','y')) + 'disableimap' => $self->option('disableimap'), + #disablepop3 (enum('n','y')) + 'disablepop3' => $self->option('disablepop3'), + #disabledeliver (enum('n','y')) + 'disabledeliver' => $self->option('disabledeliver'), + #disablesmtp (enum('n','y')) + 'disablesmtp' => $self->option('disablesmtp'), + }; +} + +sub _export_insert { + my ($self, $svc_acct) = @_; + return $self->api_error || 'Error logging in' + unless $self->api_login; + my $params = $self->_mail_user_params($svc_acct); + my $remoteid = $self->api_call('mail_user_add',$self->option('client_id'),$params); + return $self->api_error_logout if $self->api_error; + my $error = $self->set_remoteid($svc_acct,$remoteid); + $error = "Remote system updated, but error setting remoteid ($remoteid): $error" + if $error; + $self->api_logout; + return $error; +} + +sub _export_replace { + my ($self, $svc_acct, $svc_acct_old) = @_; + return $self->api_error || 'Error logging in' + unless $self->api_login; + my $remoteid = $self->get_remoteid($svc_acct_old); + return "Could not load remoteid for old service" unless $remoteid; + my $params = $self->_mail_user_params($svc_acct); + #API docs claim "Returns the number of affected rows" + my $success = $self->api_call('mail_user_update',$self->option('client_id'),$remoteid,$params); + return $self->api_error_logout if $self->api_error; + return "Server returned no rows updated, but no other error message" unless $success; + my $error = ''; + unless ($svc_acct->svcnum eq $svc_acct_old->svcnum) { # are these ever not equal? + $error = $self->set_remoteid($svc_acct,$remoteid); + $error = "Remote system updated, but error setting remoteid ($remoteid): $error" + if $error; + } + $self->api_logout; + return $error; +} + +sub _export_delete { + my ($self, $svc_acct) = @_; + return $self->api_error || 'Error logging in' + unless $self->api_login; + my $remoteid = $self->get_remoteid($svc_acct); + #don't abort deletion-- + # might have been provisioned before export was implemented, + # still need to be able to delete from freeside + unless ($remoteid) { + warn "Could not load remoteid for svcnum ".$svc_acct->svcnum.", unprovisioning anyway"; + return ''; + } + #API docs claim "Returns the number of deleted records" + my $success = $self->api_call('mail_user_delete',$remoteid); + return $self->api_error_logout if $self->api_error; + #don't abort deletion-- + # if it's already been deleted remotely, + # still need to be able to delete from freeside + warn "Server returned no records deleted for svcnum ".$svc_acct->svcnum. + " remoteid $remoteid, unprovisioning anyway" + unless $success; + $self->api_logout; + return ''; +} + +sub _export_suspend { + my ($self, $svc_acct) = @_; + return ''; +} + +sub _export_unsuspend { + my ($self, $svc_acct) = @_; + return ''; +} + +=head1 ISPConfig3 API + +These methods allow access to the ISPConfig3 API using the credentials +set in the export options. + +=cut + +=head2 api_call + +Accepts I<$method> and I<@params>. Places an api call to the specified +method with the specified params. Returns the result of that call +(empty on failure.) Retrieve error messages using L</api_error>. + +Do not include session id in list of params; it will be included +automatically. Must run L</api_login> first. + +=cut + +sub api_call { + my ($self,$method,@params) = @_; + # This does get used by api_login, + # to retrieve the session id after it sets the client, + # so we only check for existence of client, + # and we only include session id if we have one + my $client = $self->{'__ispconfig_client'}; + unless ($client) { + $self->{'__ispconfig_error'} = 'Not logged in'; + return; + } + if ($self->{'__ispconfig_session'}) { + unshift(@params,$self->{'__ispconfig_session'}); + } + # Contact server in eval, to trap connection errors + warn "Calling SOAP method $method with params:\n".Dumper(\@params)."\n" + if $self->option('debug'); + my $response = eval { $client->$method(@params) }; + unless ($response) { + $self->{'__ispconfig_error'} = "Error contacting server: $@"; + return; + } + # Set results and return + $self->{'__ispconfig_error'} = $response->fault + ? "Error from server: " . $response->faultstring + : ''; + return $response->result; +} + +=head2 api_error + +Returns the error string set by L</ISPConfig3 API> methods, +or a blank string if most recent call produced no errors. + +=cut + +sub api_error { + my $self = shift; + return $self->{'__ispconfig_error'} || ''; +} + +=head2 api_error_logout + +Attempts L</api_logout>, but returns L</api_error> message from +before logout was attempted. Useful for logging out +properly after an error. + +=cut + +sub api_error_logout { + my $self = shift; + my $error = $self->api_error; + $self->api_logout; + return $error; +} + +=head2 api_login + +Initializes an api session using the credentials for this export. +Returns true on success, false on failure. +Retrieve error messages using L</api_error>. + +=cut + +sub api_login { + my $self = shift; + if ($self->{'__ispconfig_session'} || $self->{'__ispconfig_client'}) { + $self->{'__ispconfig_error'} = 'Already logged in'; + return; + } + $self->{'__ispconfig_session'} = undef; + $self->{'__ispconfig_client'} = + SOAP::Lite->proxy( $self->option('soap_location'), + ssl_opts => [ + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + ] + ) + || undef; + unless ($self->{'__ispconfig_client'}) { + $self->{'__ispconfig_error'} = 'Error creating SOAP client'; + return; + } + $self->{'__ispconfig_session'} = + $self->api_call('login',$self->option('username'),$self->option('password')) + || undef; + return unless $self->{'__ispconfig_session'}; + return 1; +} + +=head2 api_logout + +Ends the current api session established by L</api_login>. +Returns true on success, false on failure. + +=cut + +sub api_logout { + my $self = shift; + unless ($self->{'__ispconfig_session'}) { + $self->{'__ispconfig_error'} = 'Not logged in'; + return; + } + my $result = $self->api_call('logout'); + # clear these even if there was a failure to logout + $self->{'__ispconfig_client'} = undef; + $self->{'__ispconfig_session'} = undef; + return if $self->api_error; + return 1; +} + +# false laziness with portaone export +sub _substitute { + my ($self, $string, @objects) = @_; + return '' unless $string; + foreach my $object (@objects) { + next unless $object; + my @fields = $object->fields; + push(@fields,'domain') if $object->table eq 'svc_acct'; + foreach my $field (@fields) { + next unless $field; + my $value = $object->$field; + $string =~ s/\$$field/$value/g; + } + } + # strip leading/trailing whitespace + $string =~ s/^\s//g; + $string =~ s/\s$//g; + return $string; +} + +=head1 SEE ALSO + +L<FS::part_export> + +=head1 AUTHOR + +Jonathan Prykop +jonathan@freeside.biz + +=cut + +1; + + diff --git a/FS/FS/part_export/portaone.pm b/FS/FS/part_export/portaone.pm index 2625c5741..986a556ba 100644 --- a/FS/FS/part_export/portaone.pm +++ b/FS/FS/part_export/portaone.pm @@ -40,7 +40,7 @@ tie my %options, 'Tie::IxHash', 'customer_name' => { label => 'Customer Name', default => 'FREESIDE CUST $custnum' }, 'account_id' => { label => 'Account ID', - default => 'FREESIDE SVC $svcnum' }, + default => 'SVC$svcnum' }, 'product_id' => { label => 'Account Product ID' }, 'debug' => { type => 'checkbox', label => 'Enable debug warnings' }, @@ -139,8 +139,9 @@ sub _export_insert { 'i_customer' => $i_customer, 'iso_4217' => ($conf->config('currency') || 'USD'), 'i_product' => $product_id, - 'activation_date' => time2str("%Y-%m-%d %H:%M:%S",time), + 'activation_date' => time2str("%Y-%m-%d",time), 'billing_model' => 1, # '1' for credit, '-1' for debit, could make this an export option + 'h323_password' => $svc_phone->sip_password, } },'i_account'); return $self->api_error_logout if $self->api_error; @@ -381,6 +382,7 @@ sub api_update_account { 'i_account' => $i_account, 'id' => $newid, 'i_product' => $self->option('product_id'), + 'h323_password' => $svc_phone->sip_password, }, },'i_account'); return if $self->api_error; diff --git a/FS/FS/part_export/sipwise.pm b/FS/FS/part_export/sipwise.pm index 8fec01300..9d4e3366e 100644 --- a/FS/FS/part_export/sipwise.pm +++ b/FS/FS/part_export/sipwise.pm @@ -5,6 +5,7 @@ use strict; use FS::Record qw(qsearch qsearchs dbh); use Tie::IxHash; +use IO::Socket::SSL; use LWP::UserAgent; use URI; use Cpanel::JSON::XS; @@ -801,7 +802,10 @@ sub ua { $self->{_ua} ||= do { my @opt; if ( $self->option('ssl_no_verify') ) { - push @opt, ssl_opts => { verify_hostname => 0 }; + push @opt, ssl_opts => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + }; } my $ua = LWP::UserAgent->new(@opt); $ua->credentials( diff --git a/FS/FS/part_export/test.pm b/FS/FS/part_export/test.pm index 126897c0b..392fc4feb 100644 --- a/FS/FS/part_export/test.pm +++ b/FS/FS/part_export/test.pm @@ -18,6 +18,7 @@ tie %options, 'Tie::IxHash', 'replace' => { label => 'Replace',type => 'checkbox', default => 1, }, 'suspend' => { label => 'Suspend',type => 'checkbox', default => 1, }, 'unsuspend'=>{ label => 'Unsuspend', type => 'checkbox', default => 1, }, + 'get_dids_npa_select' => { label => 'DIDs by NPA', type => 'checkbox' }, ; %info = ( @@ -31,6 +32,8 @@ or always dies, according to the "Result" option. It does nothing else; the purpose is purely to simulate success or failure within an export module.</P> <P>The checkbox options can be used to turn the export off for certain actions, if this is needed.</P> +<P>This export will produce a small set of DIDs, in either Alabama (if the +"DIDs by NPA" option is on) or California (if not).</P> END ); @@ -72,4 +75,79 @@ sub run { } } +sub can_get_dids { 1 } + +sub get_dids_npa_select { + my $self = shift; + $self->option('get_dids_npa_select') ? 1 : 0; +} + +# we don't yet have tollfree + +my $dids_by_npa = { + 'states' => [ 'AK', 'AL' ], + # states + 'AK' => [], + 'AL' => [ '205', '998', '999' ], + # NPAs + '205' => [ 'ALABASTER (205-555-XXXX)', # an NPA-NXX + 'EMPTY (205-998-XXXX)', + 'INVALID (205-999-XXXX)', + 'ALBERTVILLE, AL', # a ratecenter + ], + '998' => [], + '999' => undef, + # exchanges + '205555' => + [ + '2055550101', + '2055550102' + ], + '205998' => [], + '205999' => undef, + # ratecenters + 'ALBERTVILLE' => [ + '2055550111', + '2055550112', + ], +}, + +my $dids_by_region = { + 'states' => [ 'CA', 'CO' ], + 'CA' => [ 'CALIFORNIA', + 'EMPTY', + 'INVALID' + ], + 'CO' => [], + # regions + 'CALIFORNIA' => [ + '4155550200', + '4155550201', + ], + 'EMPTY' => [], + 'INVALID' => undef, +}; + +sub get_dids { + my $self = shift; + my %opt = @_; + my $data = $self->get_dids_npa_select ? $dids_by_npa : $dids_by_region; + + my $key; + if ( $opt{'exchange'} ) { + $key = $opt{'areacode'} . $opt{'exchange'}; + } else { + $key = $opt{'ratecenter'} + || $opt{'areacode'} + || $opt{'region'} + || $opt{'state'} + || 'states'; + } + if ( defined $data->{ $key } ) { + return $data->{ $key }; + } else { + die "[test] '$key' is invalid\n"; + } +} + 1; diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm index 0ca52a096..1d4682c1e 100644 --- a/FS/FS/part_fee.pm +++ b/FS/FS/part_fee.pm @@ -402,7 +402,8 @@ sub lineitem { # if this is a percentage fee and has line item fractions, # adjust them to be proportional and to add up correctly. - if ( @item_base ) { + # don't try this if we're charging on a zero-amount set of line items. + if ( scalar(@item_base) > 0 and $total_base > 0 ) { my $cents = $amount * 100; # not necessarily the same as percent my $multiplier = $amount / $total_base; diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 45bdc6207..008ba8a86 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -770,6 +770,40 @@ sub check { ''; } +=item check_options + +For a passed I<$options> hashref, validates any options that +have 'validate' subroutines defined in the info hash, +then validates the entire hashref if the price plan has +its own 'validate' subroutine defined in the info hash +(I<$options> values might be altered.) + +Returns error message, or empty string if valid. + +Invoked by L</insert> and L</replace> via the equivalent +methods in L<FS::option_Common>. + +=cut + +sub check_options { + my ($self,$options) = @_; + foreach my $option (keys %$options) { + if (exists $plans{ $self->plan }->{fields}->{$option}) { + if (exists($plans{$self->plan}->{fields}->{$option}->{'validate'})) { + # pass option name for use in error message + # pass a reference to the $options value, so it can be cleaned up + my $error = &{$plans{$self->plan}->{fields}->{$option}->{'validate'}}($option,\($options->{$option})); + return $error if $error; + } + } # else "option does not exist" error? + } + if (exists($plans{$self->plan}->{'validate'})) { + my $error = &{$plans{$self->plan}->{'validate'}}($options); + return $error if $error; + } + return ''; +} + =item check_pkg_svc Checks pkg_svc records as a whole (for part_svc_link dependencies). @@ -1119,14 +1153,11 @@ sub pkg_svc { # qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } ); my $opt = ref($_[0]) ? $_[0] : { @_ }; - my %pkg_svc = map { $_->svcpart => $_ } - grep { $_->quantity } - qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } ); + my %pkg_svc = map { $_->svcpart => $_ } $self->_pkg_svc; unless ( $opt->{disable_linked} ) { foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) { - my @pkg_svc = grep { $_->quantity } - qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } ); + my @pkg_svc = $dst_pkg->_pkg_svc; foreach my $pkg_svc ( @pkg_svc ) { if ( $pkg_svc{$pkg_svc->svcpart} ) { my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity; @@ -1146,6 +1177,17 @@ sub pkg_svc { } +sub _pkg_svc { + my $self = shift; + grep { $_->quantity } + qsearch({ + 'select' => 'pkg_svc.*, part_svc.*', + 'table' => 'pkg_svc', + 'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )', + 'hashref' => { 'pkgpart' => $self->pkgpart }, + }); +} + =item svcpart [ SVCDB ] Returns the svcpart of the primary service definition (see L<FS::part_svc>) @@ -1416,9 +1458,8 @@ sub option { my( $self, $opt, $ornull ) = @_; #cache: was pulled up in the original part_pkg query - if ( $opt =~ /^(setup|recur)_fee$/ && defined($self->hashref->{"_$opt"}) ) { - return $self->hashref->{"_$opt"}; - } + return $self->hashref->{"_opt_$opt"} + if exists $self->hashref->{"_opt_$opt"}; cluck "$self -> option: searching for $opt" if $DEBUG; my $part_pkg_option = @@ -1974,6 +2015,18 @@ sub recur_margin_permonth { $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_); } +=item intro_end PACKAGE + +Takes an L<FS::cust_pkg> object. If this plan has an introductory rate, +returns the expected date the intro period will end. If there is no intro +rate, returns zero. + +=cut + +sub intro_end { + 0; +} + =item format OPTION DATA Returns data formatted according to the function 'format' described diff --git a/FS/FS/part_pkg/flat.pm b/FS/FS/part_pkg/flat.pm index 04d761b16..84599ea8a 100644 --- a/FS/FS/part_pkg/flat.pm +++ b/FS/FS/part_pkg/flat.pm @@ -58,8 +58,9 @@ tie my %contract_years, 'Tie::IxHash', ( }, 'prorate_round_day' => { 'name' => 'When synchronizing, round the prorated '. - 'period to the nearest full day', - 'type' => 'checkbox', + 'period', + 'type' => 'select', + 'select_options' => \%FS::part_pkg::prorate_Mixin::prorate_round_day_opts, }, 'add_full_period' => { 'disabled' => 1 }, # doesn't make sense with sync? @@ -136,11 +137,7 @@ sub calc_setup { sub base_setup { my($self, $cust_pkg, $sdate, $details ) = @_; - ( exists( $self->{'Hash'}{'_opt_setup_fee'} ) - ? $self->{'Hash'}{'_opt_setup_fee'} - : $self->option('setup_fee', 1) - ) - || 0; + $self->option('setup_fee', 1) || 0; } sub calc_recur { @@ -178,7 +175,7 @@ sub cutoff_day { my $cust_pkg = shift; if ( $self->option('sync_bill_date',1) ) { my $next_bill = $cust_pkg->cust_main->next_bill_date; - if ( defined($next_bill) ) { + if ( $next_bill ) { # careful here. if the prorate calculation is going to round to # the nearest day, this needs to always return the same result if ( $self->option('prorate_round_day', 1) ) { @@ -193,11 +190,7 @@ sub cutoff_day { sub base_recur { my($self, $cust_pkg, $sdate) = @_; - ( exists( $self->{'Hash'}{'_opt_recur_fee'} ) - ? $self->{'Hash'}{'_opt_recur_fee'} - : $self->option('recur_fee', 1) - ) - || 0; + $self->option('recur_fee', 1) || 0; } sub base_recur_permonth { diff --git a/FS/FS/part_pkg/flat_introrate.pm b/FS/FS/part_pkg/flat_introrate.pm index 733760276..e43a525d2 100644 --- a/FS/FS/part_pkg/flat_introrate.pm +++ b/FS/FS/part_pkg/flat_introrate.pm @@ -4,6 +4,33 @@ use base qw( FS::part_pkg::flat ); use strict; use vars qw( %info ); +use FS::Log; + +# mostly false laziness with FS::part_pkg::global_Mixin::validate_moneyn, +# except for blank string handling... +sub validate_money { + my ($option, $valref) = @_; + if ( $$valref eq '' ) { + $$valref = '0'; + } elsif ( $$valref =~ /^\s*(\d*)(\.\d{1})\s*$/ ) { + #handle one decimal place without barfing out + $$valref = ( ($1||''). ($2.'0') ) || 0; + } elsif ( $$valref =~ /^\s*(\d*)(\.\d{2})?\s*$/ ) { + $$valref = ( ($1||''). ($2||'') ) || 0; + } else { + return "Illegal (money) $option: ". $$valref; + } + return ''; +} + +sub validate_number { + my ($option, $valref) = @_; + $$valref = 0 unless $$valref; + return "Invalid $option" + unless ($$valref) = ($$valref =~ /^\s*(\d+)\s*$/); + return ''; +} + %info = ( 'name' => 'Introductory price for X months, then flat rate,'. 'relative to setup date (anniversary billing)', @@ -12,29 +39,52 @@ use vars qw( %info ); 'fields' => { 'intro_fee' => { 'name' => 'Introductory recurring fee for this package', 'default' => 0, + 'validate' => \&validate_money, }, 'intro_duration' => { 'name' => 'Duration of the introductory period, in number of months', 'default' => 0, + 'validate' => \&validate_number, + }, + 'show_as_discount' => + { 'name' => 'Show the introductory rate on the invoice as if it\'s a discount', + 'type' => 'checkbox', }, }, - 'fieldorder' => [ qw(intro_duration intro_fee) ], + 'fieldorder' => [ qw(intro_duration intro_fee show_as_discount) ], 'weight' => 14, ); +sub intro_end { + my($self, $cust_pkg) = @_; + my ($duration) = ($self->option('intro_duration') =~ /^\s*(\d+)\s*$/); + unless (length($duration)) { + my $log = FS::Log->new('FS::part_pkg'); + $log->warning("Invalid intro_duration '".$self->option('intro_duration')."' on pkgpart ".$self->pkgpart + .", defaulting to 0, check package definition"); + $duration = 0; + } + + # no setup or start_date means "start billing the package ASAP", so assume + # it would start billing right now. + my $start = $cust_pkg->setup || $cust_pkg->start_date || time; + + $self->add_freq($start, $duration); +} + sub base_recur { my($self, $cust_pkg, $time ) = @_; - warn "flat_introrate base_recur requires date!" if !$time; - my $now = $time ? $$time : time; - - my ($duration) = ($self->option('intro_duration') =~ /^\s*(\d+)\s*$/); - unless (length($duration)) { - die "Invalid intro_duration: " . $self->option('intro_duration'); + my $now; + if (!$time) { # the "$sdate" from _make_lines + my $log = FS::Log->new('FS::part_pkg'); + $log->warning("flat_introrate base_recur requires date!"); + $now = time; + } else { + $now = $$time; } - my $intro_end = $self->add_freq($cust_pkg->setup, $duration); - if ($now < $intro_end) { + if ($now < $self->intro_end($cust_pkg)) { return $self->option('intro_fee'); } else { return $self->option('recur_fee'); @@ -42,5 +92,26 @@ sub base_recur { } +sub item_discount { + my ($self, $cust_pkg) = @_; + return unless $self->option('show_as_discount'); + my $intro_end = $self->intro_end($cust_pkg); + my $amount = sprintf('%.2f', + $self->option('intro_fee') - $self->option('recur_fee') + ); + return unless $amount < 0; + # otherwise it's an "introductory surcharge"? not the intended use of + # the feature. + + { '_is_discount' => 1, + 'description' => $cust_pkg->mt('Introductory discount until') . ' ' . + $cust_pkg->time2str_local('short', $intro_end), + 'setup_amount' => 0, + 'recur_amount' => $amount, + 'ext_description' => [], + 'pkgpart' => $self->pkgpart, + 'feepart' => '', + } +} 1; diff --git a/FS/FS/part_pkg/global_Mixin.pm b/FS/FS/part_pkg/global_Mixin.pm index 2318c3e61..e82602e1a 100644 --- a/FS/FS/part_pkg/global_Mixin.pm +++ b/FS/FS/part_pkg/global_Mixin.pm @@ -14,16 +14,35 @@ tie my %a2billing_simultaccess, 'Tie::IxHash', ( 1 => 'Enabled', ); +# much false laziness with FS::Record::ut_money +sub validate_moneyn { + my ($option, $valref) = @_; + if ( $$valref eq '' ) { + return ''; + } elsif ( $$valref =~ /^\s*(\d*)(\.\d{1})\s*$/ ) { + #handle one decimal place without barfing out + $$valref = ( ($1||''). ($2.'0') ) || 0; + } elsif ( $$valref =~ /^\s*(\d*)(\.\d{2})?\s*$/ ) { + $$valref = ( ($1||''). ($2||'') ) || 0; + } else { + return "Illegal (money) $option: ". $$valref; + } + return ''; +} + + %info = ( 'disabled' => 1, 'fields' => { 'setup_fee' => { 'name' => 'Setup fee for this package', 'default' => 0, + 'validate' => \&validate_moneyn, }, 'recur_fee' => { 'name' => 'Recurring fee for this package', 'default' => 0, + 'validate' => \&validate_moneyn, }, 'unused_credit_cancel' => { 'name' => 'Credit the customer for the unused portion of service at '. @@ -41,7 +60,7 @@ tie my %a2billing_simultaccess, 'Tie::IxHash', ( 'type' => 'checkbox', }, 'delay_cancel' => { - 'name' => 'Automatically suspend for one day before cancelling', + 'name' => 'Automatic suspension period before cancelling (configuration setting part_pkg-delay_cancel-days)', 'type' => 'checkbox', }, diff --git a/FS/FS/part_pkg/prorate.pm b/FS/FS/part_pkg/prorate.pm index a81bfda9d..4cdc3f123 100644 --- a/FS/FS/part_pkg/prorate.pm +++ b/FS/FS/part_pkg/prorate.pm @@ -22,9 +22,9 @@ use Time::Local qw(timelocal); 'type' => 'checkbox', }, 'prorate_round_day'=> { - 'name' => 'Round the prorated period to the nearest '. - 'full day', - 'type' => 'checkbox', + 'name' => 'Round the prorated period', + 'type' => 'select', + 'select_options' => \%FS::part_pkg::prorate_Mixin::prorate_round_day_opts, }, 'prorate_defer_bill'=> { 'name' => 'Defer the first bill until the billing day', diff --git a/FS/FS/part_pkg/prorate_Mixin.pm b/FS/FS/part_pkg/prorate_Mixin.pm index e8d42b9ca..a89b54d2c 100644 --- a/FS/FS/part_pkg/prorate_Mixin.pm +++ b/FS/FS/part_pkg/prorate_Mixin.pm @@ -2,10 +2,18 @@ package FS::part_pkg::prorate_Mixin; use strict; use vars qw( %info ); +use Tie::IxHash; use Time::Local qw( timelocal timelocal_nocheck ); use Date::Format qw( time2str ); use List::Util qw( min ); +tie our %prorate_round_day_opts, 'Tie::IxHash', + 0 => 'no', + 1 => 'to the nearest day', + 2 => 'up to a full day', + 3 => 'down to a full day', +; + %info = ( 'disabled' => 1, # define all fields that are referenced in this code @@ -16,8 +24,9 @@ use List::Util qw( min ); 'type' => 'checkbox', }, 'prorate_round_day' => { - 'name' => 'When prorating, round to the nearest full day', - 'type' => 'checkbox', + 'name' => 'When prorating, round the prorated period', + 'type' => 'select', + 'select_options' => \%prorate_round_day_opts, }, 'prorate_defer_bill' => { 'name' => 'When prorating, defer the first bill until the '. @@ -183,22 +192,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); - } - else { - $cust_pkg->bill($mend); + 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; } - return 1; } return 0; } @@ -219,7 +241,8 @@ sub _endpoints { # only works for freq >= 1 month; probably can't be fixed my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5]; - if( $self->option('prorate_round_day',1) ) { + my $rounding_mode = $self->option('prorate_round_day',1); + if ( $rounding_mode == 1 ) { # If the time is 12:00-23:59, move to the next day by adding 18 # hours to $mnow. Because of DST this can end up from 05:00 to 18:59 # but it's always within the next day. @@ -228,6 +251,19 @@ sub _endpoints { ($mday,$mon,$year) = (localtime($mnow))[3..5]; # Then set $mnow to midnight on that day. $mnow = timelocal(0,0,0,$mday,$mon,$year); + } elsif ( $rounding_mode == 2 ) { + # Move the time back to midnight. This increases the length of the + # prorate interval. + $mnow = timelocal(0,0,0,$mday,$mon,$year); + ($mday,$mon,$year) = (localtime($mnow))[3..5]; + } elsif ( $rounding_mode == 3 ) { + # If the time is after midnight, move it forward to the next midnight. + # This decreases the length of the prorate interval. + if ( $sec > 0 or $min > 0 or $hour > 0 ) { + # move to one second before midnight, then tick forward + $mnow = timelocal(59,59,23,$mday,$mon,$year) + 1; + ($mday,$mon,$year) = (localtime($mnow))[3..5]; + } } my $mend; my $mstart; diff --git a/FS/FS/part_pkg/rt_field.pm b/FS/FS/part_pkg/rt_field.pm new file mode 100644 index 000000000..293bfa761 --- /dev/null +++ b/FS/FS/part_pkg/rt_field.pm @@ -0,0 +1,208 @@ +package FS::part_pkg::rt_field; + +use strict; +use FS::Conf; +use FS::TicketSystem; +use FS::Record qw(qsearchs qsearch); +use FS::part_pkg::recur_Common; +use FS::part_pkg::global_Mixin; +use FS::rt_field_charge; + +our @ISA = qw(FS::part_pkg::recur_Common); + +our $DEBUG = 0; + +use vars qw( $conf $money_char ); + +FS::UID->install_callback( sub { + $conf = new FS::Conf; + $money_char = $conf->config('money_char') || '$'; +}); + +my %custom_field = ( + 'type' => 'select-rt-customfield', + 'lookuptype' => 'RT::Queue-RT::Ticket', +); + +my %multiple = ( + 'multiple' => 1, + 'parse' => sub { @_ }, # because /edit/process/part_pkg.pm doesn't grok select multiple +); + +our %info = ( + 'name' => 'Bill from custom fields in resolved RT tickets', + 'shortname' => 'RT custom rate', + 'weight' => 65, + 'inherit_fields' => [ 'global_Mixin' ], + 'fields' => { + 'queueids' => { 'name' => 'Queues', + 'type' => 'select-rt-queue', + %multiple, + 'validate' => sub { return ${$_[1]} ? '' : 'Queue must be specified' }, + }, + 'unit_field' => { 'name' => 'Units field', + %custom_field, + 'validate' => sub { return ${$_[1]} ? '' : 'Units field must be specified' }, + }, + 'rate_field' => { 'name' => 'Charge per unit (from RT field)', + %custom_field, + 'empty_label' => '', + }, + 'rate_flat' => { 'name' => 'Charge per unit (flat)', + 'validate' => \&FS::part_pkg::global_Mixin::validate_moneyn }, + 'display_fields' => { 'name' => 'Display fields', + %custom_field, + %multiple, + }, + # from global_Mixin, but don't get used by this at all + 'unused_credit_cancel' => {'disabled' => 1}, + 'unused_credit_suspend' => {'disabled' => 1}, + 'unused_credit_change' => {'disabled' => 1}, + }, + 'validate' => sub { + my $options = shift; + return 'Rate must be specified' + unless $options->{'rate_field'} or $options->{'rate_flat'}; + return 'Cannot specify both flat rate and rate field' + if $options->{'rate_field'} and $options->{'rate_flat'}; + return ''; + }, + 'fieldorder' => [ 'queueids', 'unit_field', 'rate_field', 'rate_flat', 'display_fields' ] +); + +sub price_info { + my $self = shift; + my $str = $self->SUPER::price_info; + $str .= ' plus ' if $str; + $str .= 'charge from RT'; +# takes way too long just to get a package label +# FS::TicketSystem->init(); +# my %custom_fields = FS::TicketSystem->custom_fields(); +# my $rate = $self->option('rate_flat',1); +# my $rate_field = $self->option('rate_field',1); +# my $unit_field = $self->option('unit_field'); +# $str .= $rate +# ? $money_char . sprintf("%.2",$rate) +# : $custom_fields{$rate_field}; +# $str .= ' x ' . $custom_fields{$unit_field}; + return $str; +} + +sub calc_setup { + my($self, $cust_pkg ) = @_; + $self->option('setup_fee'); +} + +sub calc_recur { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + my $charges = 0; + + $charges += $self->calc_usage(@_); + $charges += ($cust_pkg->quantity || 1) * $self->calc_recur_Common(@_); + + $charges; + +} + +sub can_discount { 0; } + +sub calc_usage { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + FS::TicketSystem->init(); + + my %queues = FS::TicketSystem->queues(undef,'SeeCustomField'); + + my @tickets; + foreach my $queueid ( + split(', ',$self->option('queueids',1) || '') + ) { + + die "Insufficient permission to invoice package" + unless exists $queues{$queueid}; + + # load all resolved tickets since pkg was ordered + # will subtract previous charges below + # only way to be sure we've caught everything + my $tickets = FS::TicketSystem->customer_tickets({ + number => $cust_pkg->custnum, + limit => 10000, # arbitrarily large + status => 'resolved', + queueid => $queueid, + resolved => $cust_pkg->order_date, # or setup? but this is mainly for installations, + # and workflow might resolve tickets before first bill... + # for now, expect pkg to be ordered before tickets get resolved, + # easy enough to make a pkg option to use setup/sdate instead + }); + push @tickets, @$tickets; + }; + + my $rate = $self->option('rate_flat',1); + my $rate_field = $self->option('rate_field',1); + my $unit_field = $self->option('unit_field'); + my @display_fields = split(', ',$self->option('display_fields',1) || ''); + + my %custom_fields = FS::TicketSystem->custom_fields(); + my $rate_label = $rate + ? '' + : ' ' . $custom_fields{$rate_field}; + my $unit_label = $custom_fields{$unit_field}; + + $rate_field = 'CF.{' . $rate_field . '}' if $rate_field; + $unit_field = 'CF.{' . $unit_field . '}'; + + my $charges = 0; + foreach my $ticket ( @tickets ) { + next unless $ticket->{$unit_field}; + next unless $rate || $ticket->{$rate_field}; + my $trate = $rate || $ticket->{$rate_field}; + my $tunit = $ticket->{$unit_field}; + my $subcharge = sprintf('%.2f', $trate * $tunit); + my $precharge = _previous_charges( $cust_pkg->pkgnum, $ticket->{'id'} ); + $subcharge -= $precharge; + + # if field values for previous charges increased, + # we can make additional charges here and now, + # but if field values were decreased, we just ignore-- + # credits will have to be applied manually later, if that's what's intended + next if $subcharge <= 0; + + my $rt_field_charge = new FS::rt_field_charge { + 'pkgnum' => $cust_pkg->pkgnum, + 'ticketid' => $ticket->{'id'}, + 'rate' => $trate, + 'units' => $tunit, + 'charge' => $subcharge, + '_date' => $$sdate, + }; + my $error = $rt_field_charge->insert; + die "Error inserting rt_field_charge: $error" if $error; + push @$details, $money_char . sprintf('%.2f',$trate) . $rate_label . ' x ' . $tunit . ' ' . $unit_label; + push @$details, ' - ' . $money_char . sprintf('%.2f',$precharge) . ' previously charged' if $precharge; + foreach my $field ( + sort { $ticket->{'_cf_sort_order'}{$a} <=> $ticket->{'_cf_sort_order'}{$b} } @display_fields + ) { + my $label = $custom_fields{$field}; + my $value = $ticket->{'CF.{' . $field . '}'}; + push @$details, $label . ': ' . $value if $value; + } + $charges += $subcharge; + } + return $charges; +} + +sub _previous_charges { + my ($pkgnum, $ticketid) = @_; + my $prev = 0; + foreach my $rt_field_charge ( + qsearch('rt_field_charge', { pkgnum => $pkgnum, ticketid => $ticketid }) + ) { + $prev += $rt_field_charge->charge; + } + return $prev; +} + +1; diff --git a/FS/FS/part_pkg/sql_external.pm b/FS/FS/part_pkg/sql_external.pm index be36c11ee..9bf107b7d 100644 --- a/FS/FS/part_pkg/sql_external.pm +++ b/FS/FS/part_pkg/sql_external.pm @@ -6,6 +6,14 @@ use vars qw( %info ); use DBI; #use FS::Record qw(qsearch qsearchs); +tie our %query_style, 'Tie::IxHash', ( + 'simple' => 'Simple (a single value for the recurring charge)', + 'detailed' => 'Detailed (multiple rows for invoice details)', +); + +our @detail_cols = ( qw(amount format duration phonenum accountcode + startdate regionname detail) + ); %info = ( 'name' => 'Base charge plus additional fees for external services from a configurable SQL query', 'shortname' => 'External SQL query', @@ -34,10 +42,17 @@ use DBI; 'query' => { 'name' => 'SQL query', 'default' => '', }, + + 'query_style' => { + 'name' => 'Query output style', + 'type' => 'select', + 'select_options' => \%query_style, + }, + }, 'fieldorder' => [qw( recur_method cutoff_day ), FS::part_pkg::prorate_Mixin::fieldorder, - qw( datasrc db_username db_password query + qw( datasrc db_username db_password query query_style )], 'weight' => '58', ); @@ -53,6 +68,7 @@ sub calc_recur { my $self = shift; my($cust_pkg, $sdate, $details, $param ) = @_; my $price = 0; + my $quantity; # can be overridden; if not we use the default my $dbh = DBI->connect( map { $self->option($_) } qw( datasrc db_username db_password ) @@ -67,9 +83,59 @@ sub calc_recur { ) { my $id = $cust_svc->svc_x->id; $sth->execute($id) or die $sth->errstr; - $price += $sth->fetchrow_arrayref->[0]; + + if ( $self->option('query_style') eq 'detailed' ) { + + while (my $row = $sth->fetchrow_hashref) { + if (exists $row->{amount}) { + if ( $row->{amount} eq '' ) { + # treat as zero + } elsif ( $row->{amount} =~ /^\d+(?:\.\d+)?$/ ) { + $price += $row->{amount}; + } else { + die "sql_external query returned non-numeric amount: $row->{amount}"; + } + } + if (defined $row->{quantity}) { + if ( $row->{quantity} eq '' ) { + # treat as zero + } elsif ( $row->{quantity} =~ /^\d+$/ ) { + $quantity += $row->{quantity}; + } else { + die "sql_external query returned non-integer quantity: $row->{quantity}"; + } + } + + my $detail = FS::cust_bill_pkg_detail->new; + foreach my $field (@detail_cols) { + if (exists $row->{$field}) { + $detail->set($field, $row->{$field}); + } + } + if (!$detail->get('detail')) { + die "sql_external query did not return detail description"; + # or make something up? + # or just don't insert the detail? + } + + push @$details, $detail; + } # while $row + + } else { + + # simple style: returns only a single value, which is the price + $price += $sth->fetchrow_arrayref->[0]; + + } + } + $price = sprintf('%.2f', $price); + + # XXX probably shouldn't allow package quantity > 1 on these packages. + if ($cust_pkg->quantity > 1) { + warn "sql_external package #".$cust_pkg->pkgnum." has quantity > 1\n"; } + $param->{'override_quantity'} = $quantity; $param->{'override_charges'} = $price; ($cust_pkg->quantity || 1) * $self->calc_recur_Common($cust_pkg,$sdate,$details,$param); } diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm index 7363700ed..7d9a7f5c9 100644 --- a/FS/FS/part_pkg/voip_cdr.pm +++ b/FS/FS/part_pkg/voip_cdr.pm @@ -149,7 +149,7 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash', # 'type' => 'checkbox', # }, - 'international_prefix' => { 'name' => 'Destination prefix for international CDR records', + 'international_prefix' => { 'name' => 'Destination prefix for international CDR records (or "none" for no prefix)', 'default' => '011', }, @@ -185,7 +185,10 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash', 'skip_dst_prefix' => { 'name' => 'Do not charge for CDRs where the destination number starts with any of these values: ', }, - 'skip_dcontext' => { 'name' => 'Do not charge for CDRs where the dcontext is set to any of these (comma-separated) values: ', + 'skip_dcontext' => { 'name' => 'Do not charge for CDRs where dcontext is set to any of these (comma-separated) values: ', + }, + + 'skip_dcontext_suffix' => { 'name' => 'Do not charge for CDRs where dcontext ends with: ', }, 'skip_dstchannel_prefix' => { 'name' => 'Do not charge for CDRs where the dstchannel starts with:', @@ -333,7 +336,7 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash', use_cdrtypenum ignore_cdrtypenum use_calltypenum ignore_calltypenum ignore_disposition disposition_in - skip_dcontext skip_dst_prefix + skip_dcontext skip_dcontext_suffix skip_dst_prefix skip_dstchannel_prefix skip_src_length_more noskip_src_length_accountcode_tollfree accountcode_tollfree_ratenum accountcode_tollfree_field @@ -401,8 +404,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'; @@ -586,6 +591,11 @@ sub check_chargable { if $self->option_cacheable('skip_dcontext') =~ /\S/ && grep { $cdr->dcontext eq $_ } split(/\s*,\s*/, $self->option_cacheable('skip_dcontext')); + my $len_suffix = length($self->option_cacheable('skip_dcontext_suffix')); + return "dcontext ends with ". $self->option_cacheable('skip_dcontext_suffix') + if $len_suffix + && substr($cdr->dcontext,-$len_suffix,$len_suffix) eq $self->option_cacheable('skip_dcontext_suffix'); + my $len_prefix = length($self->option_cacheable('skip_dstchannel_prefix')); return "dstchannel starts with ". $self->option_cacheable('skip_dstchannel_prefix') if $len_prefix @@ -664,7 +674,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..e911439c8 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'; @@ -291,10 +292,7 @@ sub calc_usage { my @call_details = ( $cdr->downstream_csv( 'format' => $output_format, 'charge' => $charge, - 'seconds' => ($use_duration - ? $cdr->duration - : $cdr->billsec - ), + 'seconds' => $seconds, 'granularity' => $granularity, ) ); @@ -316,10 +314,10 @@ sub calc_usage { 'done', $charge, $cust_svc->svcnum, - 'rated_seconds' => $use_duration ? $cdr->duration : $cdr->billsec, + 'rated_seconds' => $seconds, 'rated_granularity' => $granularity, 'rated_classnum' => $cdr->calltypenum, - 'inbound' => 1, + 'inbound' => 1, # to update cdr_termination, not cdr ); die $error if $error; $formatter->append($cdr); 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/part_referral.pm b/FS/FS/part_referral.pm index e4a582374..2df8a7571 100644 --- a/FS/FS/part_referral.pm +++ b/FS/FS/part_referral.pm @@ -44,6 +44,9 @@ The following fields are currently supported: =item agentnum - Optional agentnum (see L<FS::agent>) +=item title - an optional external string that identifies this +referral source, such as an advertising campaign code. + =back =head1 NOTE @@ -101,6 +104,7 @@ sub check { || $self->ut_text('referral') || $self->ut_enum('disabled', [ '', 'Y' ] ) #|| $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum') + || $self->ut_textn('title') || ( $setup_hack ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' ) : $self->ut_agentnum_acl('agentnum', 'Edit global advertising sources') diff --git a/FS/FS/part_svc.pm b/FS/FS/part_svc.pm index 612c59013..dcc78435b 100644 --- a/FS/FS/part_svc.pm +++ b/FS/FS/part_svc.pm @@ -1,5 +1,5 @@ package FS::part_svc; -use base qw(FS::Record); +use base qw(FS::o2m_Common FS::Record); use strict; use vars qw( $DEBUG ); @@ -11,6 +11,7 @@ use FS::part_export; use FS::export_svc; use FS::cust_svc; use FS::part_svc_class; +use FS::part_svc_msgcat; FS::UID->install_callback(sub { # preload the cache and make sure all modules load @@ -590,6 +591,26 @@ sub num_cust_svc { $sth->fetchrow_arrayref->[0]; } +=item num_cust_svc_cancelled + +Returns the number of associated customer services that are +attached to cancelled packages. + +=cut + +sub num_cust_svc_cancelled { + my $self = shift; + my $sth = dbh->prepare( + "SELECT COUNT(*) FROM cust_svc + LEFT JOIN cust_pkg USING ( pkgnum ) + WHERE svcpart = ? + AND cust_pkg.cancel IS NOT NULL" + ) or die dbh->errstr; + $sth->execute($self->svcpart) + or die $sth->errstr; + $sth->fetchrow_arrayref->[0]; +} + =item svc_x Returns a list of associated FS::svc_* records. @@ -601,6 +622,24 @@ sub svc_x { map { $_->svc_x } $self->cust_svc; } +=item svc_locale LOCALE + +Returns a customer-viewable service definition label in the chosen LOCALE. +If there is no entry for that locale or if LOCALE is empty, returns +part_svc.svc. + +=cut + +sub svc_locale { + my( $self, $locale ) = @_; + return $self->svc unless $locale; + my $part_svc_msgcat = qsearchs('part_svc_msgcat', { + svcpart => $self->svcpart, + locale => $locale + }) or return $self->svc; + $part_svc_msgcat->svc; +} + =back =head1 CLASS METHODS @@ -863,6 +902,12 @@ sub process { $param->{'svcpart'} = $new->getfield('svcpart'); } + $error ||= $new->process_o2m( + 'table' => 'part_svc_msgcat', + 'params' => $param, + 'fields' => [ 'locale', 'svc' ], + ); + die "$error\n" if $error; } diff --git a/FS/FS/part_svc_msgcat.pm b/FS/FS/part_svc_msgcat.pm new file mode 100644 index 000000000..6d69198ec --- /dev/null +++ b/FS/FS/part_svc_msgcat.pm @@ -0,0 +1,131 @@ +package FS::part_svc_msgcat; +use base qw( FS::Record ); + +use strict; +use FS::Locales; + +=head1 NAME + +FS::part_svc_msgcat - Object methods for part_svc_msgcat records + +=head1 SYNOPSIS + + use FS::part_svc_msgcat; + + $record = new FS::part_svc_msgcat \%hash; + $record = new FS::part_svc_msgcat { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::part_svc_msgcat object represents localized labels of a service +definition. FS::part_svc_msgcat inherits from FS::Record. The following +fields are currently supported: + +=over 4 + +=item svcpartmsgnum + +primary key + +=item svcpart + +Service definition + +=item locale + +locale + +=item svc + +Localized service name (customer-viewable) + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I<hash> method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'part_svc_msgcat'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=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. + +=cut + +# the replace method can be inherited from FS::Record + +=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 + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('svcpartmsgnum') + || $self->ut_foreign_key('svcpart', 'part_svc', 'svcpart') + || $self->ut_enum('locale', [ FS::Locales->locales ] ) + || $self->ut_text('svc') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L<FS::part_svc>, L<FS::Record>, schema.html from the base documentation. + +=cut + +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/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 41768189e..5f7ce3550 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -5,6 +5,7 @@ use Business::CreditCard; use FS::payby; use FS::Record qw(qsearch); use FS::UID qw(driver_name); +use FS::Cursor; use Time::Local qw(timelocal); use vars qw($ignore_masked_payinfo); @@ -193,7 +194,12 @@ sub payinfo_check { or return "Illegal payby: ". $self->payby; if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) { + my $payinfo = $self->payinfo; + my $cardtype = cardtype($payinfo); + $cardtype = 'Tokenized' if $payinfo =~ /^99\d{14}$/; + $self->set('paycardtype', $cardtype); + if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) { # allow it } else { @@ -204,13 +210,18 @@ sub payinfo_check { or return "Illegal (mistyped?) credit card number (payinfo)"; $self->payinfo($1); validate($self->payinfo) or return "Illegal credit card number"; - return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token - && cardtype($self->payinfo) eq "Unknown"; + return "Unknown card type" if $cardtype eq "Unknown"; } else { $self->payinfo('N/A'); #??? } } } else { + if ( $self->payby eq 'CARD' and $self->paymask ) { + # if we can't decrypt the card, at least detect the cardtype + $self->set('paycardtype', cardtype($self->paymask)); + } else { + $self->set('paycardtype', ''); + } if ( $self->is_encrypted($self->payinfo) ) { #something better? all it would cause is a decryption error anyway? my $error = $self->ut_anything('payinfo'); @@ -404,6 +415,43 @@ sub paydate_epoch_sql { END" } +=item upgrade_set_cardtype + +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 => $table, + extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ], + }); + while (my $record = $search->fetch) { + my $error = $record->replace; + die $error if $error; + } +} + =back =head1 BUGS diff --git a/FS/FS/pkg_svc.pm b/FS/FS/pkg_svc.pm index b2dc87042..5c6070303 100644 --- a/FS/FS/pkg_svc.pm +++ b/FS/FS/pkg_svc.pm @@ -2,6 +2,17 @@ package FS::pkg_svc; use base qw(FS::Record); use strict; +use FS::Record qw( qsearchs ); +use FS::part_svc; + +our $cache_enabled = 0; + +sub _simplecache { + my( $self, $hashref ) = @_; + if ( $cache_enabled && $hashref->{'svc'} ) { + $self->{'_svcpart'} = FS::part_svc->new($hashref); + } +} =head1 NAME @@ -132,6 +143,14 @@ Returns the FS::part_pkg object (see L<FS::part_pkg>). Returns the FS::part_svc object (see L<FS::part_svc>). +=cut + +sub part_svc { + my $self = shift; + return $self->{_svcpart} if $self->{_svcpart}; + qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } ); +} + =back =head1 BUGS diff --git a/FS/FS/prospect_main.pm b/FS/FS/prospect_main.pm index f600b23e9..67e91cf99 100644 --- a/FS/FS/prospect_main.pm +++ b/FS/FS/prospect_main.pm @@ -270,8 +270,11 @@ sub name { my $self = shift; return $self->company if $self->company; - my $contact = ($self->prospect_contact)[0]->contact; #first contact? good enough for now - return $contact->line if $contact; + my $prospect_contact = ($self->prospect_contact)[0]; #first contact? good enough for now + my $contact = $prospect_contact->contact if $prospect_contact; + return $contact->line if $prospect_contact && $contact; + + #address? 'Prospect #'. $self->prospectnum; } @@ -352,9 +355,6 @@ sub convert_cust_main { my @contact = map $_->contact, $self->prospect_contact; - #XXX define one contact type as "billing", then we could pick just that one - my @invoicing_list = map $_->emailaddress, map $_->contact_email, @contact; - #XXX i'm not compatible with cust_main-require_phone (which is kind of a # pre-contact thing anyway) @@ -379,7 +379,7 @@ sub convert_cust_main { #$cust_main->payby('BILL'); #$cust_main->paydate('12/2037'); - $cust_main->insert( {}, \@invoicing_list, + $cust_main->insert( {}, 'prospectnum' => $self->prospectnum, ) or $cust_main; diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm index 8e32eff29..c61e001c6 100644 --- a/FS/FS/quotation.pm +++ b/FS/FS/quotation.pm @@ -280,8 +280,8 @@ sub _items_sections { my $part_pkg = $pkg->part_pkg; my $recur_freq = $part_pkg->freq; - $show{$recur_freq} = 1 if $pkg->unitrecur > 0; - $show{0} = 1 if $pkg->unitsetup > 0; + $show{$recur_freq} = 1 if $pkg->unitrecur > 0 or $pkg->recur_show_zero; + $show{0} = 1 if $pkg->unitsetup > 0 or $pkg->setup_show_zero; ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax; ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax; @@ -350,7 +350,7 @@ sub _items_sections { sub enable_previous { 0 } -=item convert_cust_main +=item convert_cust_main [ PARAMS ] If this quotation already belongs to a customer, then returns that customer, as an FS::cust_main object. @@ -362,10 +362,13 @@ packages as real packages for the customer. If there is an error, returns an error message, otherwise, returns the newly-created FS::cust_main object. +Accepts the same params as L</order>. + =cut sub convert_cust_main { my $self = shift; + my $params = shift || {}; my $cust_main = $self->cust_main; return $cust_main if $cust_main; #already converted, don't again @@ -382,7 +385,7 @@ sub convert_cust_main { $self->prospectnum(''); $self->custnum( $cust_main->custnum ); - my $error = $self->replace || $self->order; + my $error = $self->replace || $self->order(undef,$params); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -394,7 +397,7 @@ sub convert_cust_main { } -=item order [ HASHREF ] +=item order [ HASHREF ] [ PARAMS ] This method is for use with quotations which are already associated with a customer. @@ -406,11 +409,16 @@ If HASHREF is passed, it will be filled with a hash mapping the C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package as ordered. +If PARAMS hashref is passed, the following params are accepted: + +onhold - if true, suspends newly ordered packages + =cut sub order { my $self = shift; my $pkgnum_map = shift || {}; + my $params = shift || {}; my $details_map = {}; tie my %all_cust_pkg, 'Tie::RefHash'; @@ -461,10 +469,11 @@ sub order { } } - foreach my $quotationpkgnum (keys %$pkgnum_map) { - # convert the objects to just pkgnums - my $cust_pkg = $pkgnum_map->{$quotationpkgnum}; - $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum; + if ($$params{'onhold'}) { + foreach my $quotationpkgnum (keys %$pkgnum_map) { + last if $error; + $error = $pkgnum_map->{$quotationpkgnum}->suspend(); + } } if ($error) { @@ -473,6 +482,13 @@ sub order { } $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + foreach my $quotationpkgnum (keys %$pkgnum_map) { + # convert the objects to just pkgnums + my $cust_pkg = $pkgnum_map->{$quotationpkgnum}; + $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum; + } + ''; #no error } @@ -1053,7 +1069,11 @@ sub _items_pkg { $quotation_pkg->get('unit'.$setuprecur)); $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'} * $quotation_pkg->quantity); - next if $this_item->{'amount'} == 0; + next if $this_item->{'amount'} == 0 and !( + $setuprecur eq 'setup' + ? $quotation_pkg->setup_show_zero + : $quotation_pkg->recur_show_zero + ); if ( $preref ) { $this_item->{'preref_html'} = &$preref($quotation_pkg); diff --git a/FS/FS/quotation_pkg.pm b/FS/FS/quotation_pkg.pm index b9b37991a..9854c4594 100644 --- a/FS/FS/quotation_pkg.pm +++ b/FS/FS/quotation_pkg.pm @@ -327,6 +327,11 @@ sub setup { } +sub setup_show_zero { + my $self = shift; + return $self->part_pkg->setup_show_zero; +} + sub setup_tax { my $self = shift; sum(0, map { $_->setup_amount } $self->quotation_pkg_tax); @@ -342,6 +347,11 @@ sub recur { } +sub recur_show_zero { + my $self = shift; + return $self->part_pkg->recur_show_zero; +} + sub recur_tax { my $self = shift; sum(0, map { $_->recur_amount } $self->quotation_pkg_tax); diff --git a/FS/FS/reason_Mixin.pm b/FS/FS/reason_Mixin.pm index 9c436ab1e..a1b32f2b5 100644 --- a/FS/FS/reason_Mixin.pm +++ b/FS/FS/reason_Mixin.pm @@ -22,13 +22,8 @@ voided payment / voided invoice. This can no longer be used to set the sub reason { my $self = shift; - my $reason_text; - if ( $self->reasonnum ) { - my $reason = FS::reason->by_key($self->reasonnum); - $reason_text = $reason->reason; - } else { # in case one of these somehow still exists - $reason_text = $self->get('reason'); - } + my $reason_text = $self->reason_only; + if ( $self->get('addlinfo') ) { $reason_text .= ' ' . $self->get('addlinfo'); } @@ -36,6 +31,28 @@ sub reason { return $reason_text; } +=item reason_only + +Returns only the text of the associated reason, +absent any addlinfo that is included by L</reason>. +(Currently only affects credit and credit void reasons.) + +=cut + +# a bit awkward, but much easier to invoke this in the few reports +# that need separate fields than to update every place +# that displays them together + +sub reason_only { + my $self = shift; + if ( $self->reasonnum ) { + my $reason = FS::reason->by_key($self->reasonnum); + return $reason->reason; + } else { # in case one of these somehow still exists + return $self->get('reason'); + } +} + # Used by FS::Upgrade to migrate reason text fields to reasonnum. # Note that any new tables that get reasonnum fields do NOT need to be # added here unless they have previously had a free-text "reason" field. diff --git a/FS/FS/rt_field_charge.pm b/FS/FS/rt_field_charge.pm new file mode 100644 index 000000000..fb01f810e --- /dev/null +++ b/FS/FS/rt_field_charge.pm @@ -0,0 +1,132 @@ +package FS::rt_field_charge; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::rt_field_charge - Object methods for rt_field_charge records + +=head1 SYNOPSIS + + use FS::rt_field_charge; + + $record = new FS::rt_field_charge \%hash; + $record = new FS::rt_field_charge { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::rt_field_charge object represents an individual charge +that has been added to an invoice by a package with the rt_field price plan. +FS::rt_field_charge inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item rtfieldchargenum - primary key + +=item pkgnum - cust_pkg that generated the charge + +=item ticketid - RT ticket that generated the charge + +=item rate - the rate per unit for the charge + +=item units - quantity of units being charged + +=item charge - the total amount charged + +=item _date - billing date for the charge + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new object. To add the object to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I<hash> method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'rt_field_charge'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=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. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid object. 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('rtfieldchargenum') + || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum' ) + || $self->ut_number('ticketid') + || $self->ut_money('rate') + || $self->ut_float('units') + || $self->ut_money('charge') + || $self->ut_number('_date') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + + + +=head1 SEE ALSO + +L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/session.pm b/FS/FS/session.pm index 615c8ae8b..78af2e9ad 100644 --- a/FS/FS/session.pm +++ b/FS/FS/session.pm @@ -1,7 +1,7 @@ package FS::session; use strict; -use vars qw( @ISA $conf $start $stop ); +use vars qw( @ISA $conf ); use FS::UID qw( dbh ); use FS::Record qw( qsearchs ); use FS::svc_acct; @@ -12,8 +12,6 @@ use FS::nas; $FS::UID::callback{'FS::session'} = sub { $conf = new FS::Conf; - $start = $conf->exists('session-start') ? $conf->config('session-start') : ''; - $stop = $conf->exists('session-stop') ? $conf->config('session-stop') : ''; }; =head1 NAME @@ -126,7 +124,6 @@ sub insert { my $nas = qsearchs('nas',{'nasnum'=>$port->nasnum}); #kcuy my( $ip, $nasip, $nasfqdn ) = ( $port->ip, $nas->nasip, $nas->nasfqdn ); - system( eval qq("$start") ) if $start; $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; @@ -186,7 +183,6 @@ sub replace { my $nas = qsearchs('nas',{'nasnum'=>$port->nasnum}); #kcuy my( $ip, $nasip, $nasfqdn ) = ( $port->ip, $nas->nasip, $nas->nasfqdn ); - system( eval qq("$stop") ) if $stop; $dbh->commit or die $dbh->errstr if $oldAutoCommit; diff --git a/FS/FS/svc_Common.pm b/FS/FS/svc_Common.pm index 73658f67a..f2456a56f 100644 --- a/FS/FS/svc_Common.pm +++ b/FS/FS/svc_Common.pm @@ -719,6 +719,8 @@ sub setx { sub part_svc { my $self = shift; + cluck 'svc_X->part_svc called' if $DEBUG; + #get part_svc my $svcpart; if ( $self->get('svcpart') ) { @@ -1385,11 +1387,17 @@ Parameters: =item order_by +=item cancelled - if true, only returns svcs attached to cancelled pkgs; +if defined and false, only returns svcs not attached to cancelled packages + =back =cut -# svc_broadband::search should eventually use this instead +### Don't call the 'cancelled' option 'Service Status' +### There is no such thing +### See cautionary note in httemplate/browse/part_svc.cgi + sub search { my ($class, $params) = @_; @@ -1473,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 @@ -1493,6 +1505,14 @@ sub search { push @where, "exportnum = $1"; } + if ( defined($params->{'cancelled'}) ) { + if ($params->{'cancelled'}) { + push @where, "cust_pkg.cancel IS NOT NULL"; + } else { + push @where, "cust_pkg.cancel IS NULL"; + } + } + # # sector and tower # my @where_sector = $class->tower_sector_sql($params); # if ( @where_sector ) { diff --git a/FS/FS/svc_IP_Mixin.pm b/FS/FS/svc_IP_Mixin.pm index 5b06082a1..8b2b5f17e 100644 --- a/FS/FS/svc_IP_Mixin.pm +++ b/FS/FS/svc_IP_Mixin.pm @@ -1,9 +1,12 @@ package FS::svc_IP_Mixin; +use base 'FS::IP_Mixin'; use strict; -use base 'FS::IP_Mixin'; -use FS::Record qw(qsearchs qsearch); use NEXT; +use FS::Record qw(qsearchs qsearch); +use FS::Conf; +use FS::router; +use FS::part_svc_router; =item addr_block @@ -183,17 +186,21 @@ means "Framed-Route" if there's an attached router. sub radius_reply { my $self = shift; - my %reply; - my ($block) = $self->attached_block; - if ( $block ) { + + my %reply = (); + + if ( my $block = $self->attached_block ) { # block routed over dynamic IP: "192.168.100.0/29 0.0.0.0 1" # or # block routed over fixed IP: "192.168.100.0/29 192.168.100.1 1" # (the "1" at the end is the route metric) - $reply{'Framed-Route'} = - $block->cidr . ' ' . - ($self->ip_addr || '0.0.0.0') . ' 1'; + $reply{'Framed-Route'} = $block->cidr . ' ' . + ($self->ip_addr || '0.0.0.0') . ' 1'; } + + $reply{'Motorola-Canopy-Gateway'} = $self->addr_block->ip_gateway + if FS::Conf->new->exists('radius-canopy') && $self->addr_block; + %reply; } diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm index b4db082e1..b659b01b2 100644 --- a/FS/FS/svc_acct.pm +++ b/FS/FS/svc_acct.pm @@ -67,11 +67,11 @@ FS::UID->install_callback( sub { @shells = $conf->config('shells'); $usernamemin = $conf->config('usernamemin') || 2; $usernamemax = $conf->config('usernamemax'); - $passwordmin = $conf->config('passwordmin'); # || 6; - #blank->6, keep 0 + $passwordmin = $conf->config('passwordmin'); + #blank->8, keep 0 $passwordmin = ( defined($passwordmin) && $passwordmin =~ /\d+/ ) ? $passwordmin - : 6; + : 8; $passwordmax = $conf->config('passwordmax') || 12; $username_letter = $conf->exists('username-letter'); $username_letterfirst = $conf->exists('username-letterfirst'); @@ -316,6 +316,7 @@ sub table_info { 'domsvc' => { label => 'Domain', type => 'select', + select_svc => 1, select_table => 'svc_domain', select_key => 'svcnum', select_label => 'domain', @@ -749,18 +750,6 @@ sub insert { } } - #welcome email - my @welcome_exclude_svcparts = $conf->config('svc_acct_welcome_exclude'); - unless ( grep { $_ eq $self->svcpart } @welcome_exclude_svcparts ) { - my $error = ''; - my $msgnum = $conf->config('welcome_msgnum', $agentnum); - if ( $msgnum ) { - my $msg_template = qsearchs('msg_template', { msgnum => $msgnum }); - $error = $msg_template->send('cust_main' => $cust_main, - 'object' => $self); - #should this do something on error? - } - } } # if $cust_pkg $dbh->commit or die $dbh->errstr if $oldAutoCommit; diff --git a/FS/FS/svc_fiber.pm b/FS/FS/svc_fiber.pm index c604943d8..00b4e0e8a 100644 --- a/FS/FS/svc_fiber.pm +++ b/FS/FS/svc_fiber.pm @@ -187,14 +187,15 @@ sub search_sql { =item label -Returns a description of this fiber service containing the circuit ID -and the ONT serial number. +Returns a description of this fiber service containing the ONT serial number +and the OLT name and port location. =cut sub label { my $self = shift; - $self->ont_serial . ' @ ' . $self->circuit_id; + $self->ont_serial . ' @ ' . $self->fiber_olt->description . ' ' . + join('-', $self->shelf, $self->card, $self->olt_port); } # nothing special for insert, delete, or replace diff --git a/FS/FS/svc_forward.pm b/FS/FS/svc_forward.pm index 5612cfc33..044e41da9 100644 --- a/FS/FS/svc_forward.pm +++ b/FS/FS/svc_forward.pm @@ -141,34 +141,6 @@ If I<depend_jobnum> is set (to a scalar jobnum or an array reference of jobnums), all provisioning jobs will have a dependancy on the supplied jobnum(s) (they will not run until the specific job(s) complete(s)). -=cut - -sub insert { - my $self = shift; - my $error; - - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; - - my $oldAutoCommit = $FS::UID::AutoCommit; - local $FS::UID::AutoCommit = 0; - my $dbh = dbh; - - $error = $self->SUPER::insert(@_); - if ($error) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; #no error - -} - =item delete Deletes this mail forwarding alias from the database. If there is an error, @@ -176,33 +148,6 @@ returns the error, otherwise returns false. The corresponding FS::cust_svc record will be deleted as well. -=cut - -sub delete { - my $self = shift; - - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; - - my $oldAutoCommit = $FS::UID::AutoCommit; - local $FS::UID::Autocommit = 0; - my $dbh = dbh; - - my $error = $self->SUPER::delete(@_); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; -} - - =item replace OLD_RECORD Replaces OLD_RECORD with this one in the database. If there is an error, @@ -221,25 +166,7 @@ sub replace { return "Can't change both source and destination of a mail forward!" } - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; - - my $oldAutoCommit = $FS::UID::AutoCommit; - local $FS::UID::AutoCommit = 0; - my $dbh = dbh; - - my $error = $new->SUPER::replace($old, @_); - if ($error) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; + $new->SUPER::replace($old, @_); } =item suspend diff --git a/FS/FS/svc_pbx.pm b/FS/FS/svc_pbx.pm index b28f0573d..a5e181d9d 100644 --- a/FS/FS/svc_pbx.pm +++ b/FS/FS/svc_pbx.pm @@ -141,18 +141,6 @@ otherwise returns false. The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be defined. An FS::cust_svc record will be created and inserted. -=cut - -sub insert { - my $self = shift; - my $error; - - $error = $self->SUPER::insert; - return $error if $error; - - ''; -} - =item delete Delete this record from the database. @@ -206,18 +194,6 @@ sub delete { Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. -=cut - -#sub replace { -# my ( $new, $old ) = ( shift, shift ); -# my $error; -# -# $error = $new->SUPER::replace($old); -# return $error if $error; -# -# ''; -#} - =item suspend Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>). diff --git a/FS/FS/svc_port.pm b/FS/FS/svc_port.pm index 9d2dae8c8..e72ac4933 100644 --- a/FS/FS/svc_port.pm +++ b/FS/FS/svc_port.pm @@ -107,52 +107,15 @@ otherwise returns false. The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be defined. An FS::cust_svc record will be created and inserted. -=cut - -sub insert { - my $self = shift; - my $error; - - $error = $self->SUPER::insert; - return $error if $error; - - ''; -} - =item delete Delete this record from the database. -=cut - -sub delete { - my $self = shift; - my $error; - - $error = $self->SUPER::delete; - return $error if $error; - - ''; -} - - =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. -=cut - -sub replace { - my ( $new, $old ) = ( shift, shift ); - my $error; - - $error = $new->SUPER::replace($old); - return $error if $error; - - ''; -} - =item suspend Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>). diff --git a/FS/FS/tower_sector.pm b/FS/FS/tower_sector.pm index 4fbd89ca2..3fadc8685 100644 --- a/FS/FS/tower_sector.pm +++ b/FS/FS/tower_sector.pm @@ -1,6 +1,10 @@ package FS::tower_sector; use base qw( FS::Record ); +use Class::Load qw(load_class); +use File::Path qw(make_path); +use Data::Dumper; + use strict; =head1 NAME @@ -24,7 +28,7 @@ FS::tower_sector - Object methods for tower_sector records =head1 DESCRIPTION -An FS::tower_sector object represents an tower sector. FS::tower_sector +An FS::tower_sector object represents a tower sector. FS::tower_sector inherits from FS::Record. The following fields are currently supported: =over 4 @@ -45,6 +49,44 @@ sectorname ip_addr +=item height + +The height of this antenna on the tower, measured from ground level. This +plus the tower's altitude should equal the height of the antenna above sea +level. + +=item freq_mhz + +The band center frequency in MHz. + +=item direction + +The antenna beam direction in degrees from north. + +=item width + +The -3dB horizontal beamwidth in degrees. + +=item downtilt + +The antenna beam elevation in degrees below horizontal. + +=item v_width + +The -3dB vertical beamwidth in degrees. + +=item margin + +The signal loss margin allowed on the sector, in dB. This is normally +transmitter EIRP minus receiver sensitivity. + +=item image + +The coverage map, as a PNG. + +=item west, east, south, north + +The coordinate boundaries of the coverage map. =back @@ -84,11 +126,6 @@ sub delete { $self->SUPER::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 sector. If there is @@ -109,7 +146,15 @@ sub check { || $self->ut_numbern('freq_mhz') || $self->ut_numbern('direction') || $self->ut_numbern('width') + || $self->ut_numbern('v_width') + || $self->ut_numbern('downtilt') || $self->ut_floatn('sector_range') + || $self->ut_numbern('margin') + || $self->ut_anything('image') + || $self->ut_sfloatn('west') + || $self->ut_sfloatn('east') + || $self->ut_sfloatn('south') + || $self->ut_sfloatn('north') ; return $error if $error; @@ -140,8 +185,112 @@ sub description { Returns the services on this tower sector. +=item need_fields_for_coverage + +Returns a list of required fields for the coverage map that aren't yet filled. + +=cut + +sub need_fields_for_coverage { + my $self = shift; + my $tower = $self->tower; + my %fields = ( + height => 'Height', + freq_mhz => 'Frequency', + direction => 'Direction', + downtilt => 'Downtilt', + width => 'Horiz. width', + v_width => 'Vert. width', + margin => 'Signal margin', + latitude => 'Latitude', + longitude => 'Longitude', + ); + my @need; + foreach (keys %fields) { + if ($self->get($_) eq '' and $tower->get($_) eq '') { + push @need, $fields{$_}; + } + } + @need; +} + +=item queue_generate_coverage + +Starts a job to recalculate the coverage map. + +=cut + +sub queue_generate_coverage { + my $self = shift; + if ( length($self->image) > 0 ) { + foreach (qw(image west south east north)) { + $self->set($_, ''); + } + my $error = $self->replace; + return $error if $error; + } + my $job = FS::queue->new({ + job => 'FS::tower_sector::process_generate_coverage', + }); + $job->insert('_JOB', { sectornum => $self->sectornum}); +} + =back +=head1 SUBROUTINES + +=over 4 + +=item process_generate_coverage JOB, PARAMS + +Queueable routine to fetch the sector coverage map from the tower mapping +server and store it. Highly experimental. Requires L<Map::Splat> to be +installed. + +PARAMS must include 'sectornum'. + +=cut + +sub process_generate_coverage { + my $job = shift; + my $param = shift; + $job->update_statustext('0,generating map') if $job; + my $sectornum = $param->{sectornum}; + my $sector = FS::tower_sector->by_key($sectornum) + or die "sector $sectornum does not exist"; + my $tower = $sector->tower; + + load_class('Map::Splat'); + # since this is still experimental, put it somewhere we can find later + my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" . + "generate_coverage/sector$sectornum-". time; + make_path($workdir); + my $splat = Map::Splat->new( + lon => $tower->longitude, + lat => $tower->latitude, + height => ($sector->height || $tower->height || 0), + freq => $sector->freq_mhz, + azimuth => $sector->direction, + h_width => $sector->width, + tilt => $sector->downtilt, + v_width => $sector->v_width, + max_loss => $sector->margin, + min_loss => $sector->margin - 80, + dir => $workdir, + ); + $splat->calculate; + + my $box = $splat->box; + foreach (qw(west east south north)) { + $sector->set($_, $box->{$_}); + } + $sector->set('image', $splat->mask); + # mask returns a PNG where everything below max_loss is solid colored, + # and everything above it is transparent. More useful for our purposes. + my $error = $sector->replace; + die $error if $error; +} + =head1 BUGS =head1 SEE ALSO diff --git a/FS/FS/webservice_log.pm b/FS/FS/webservice_log.pm new file mode 100644 index 000000000..7e320c23e --- /dev/null +++ b/FS/FS/webservice_log.pm @@ -0,0 +1,137 @@ +package FS::webservice_log; +use base qw( FS::Record ); + +use strict; + +=head1 NAME + +FS::webservice_log - Object methods for webservice_log records + +=head1 SYNOPSIS + + use FS::webservice_log; + + $record = new FS::webservice_log \%hash; + $record = new FS::webservice_log { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::webservice_log object represents an web service log entry. +FS::webservice_log inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item webservicelognum + +primary key + +=item svcnum + +svcnum + +=item custnum + +custnum + +=item method + +method + +=item quantity + +quantity + +=item _date + +_date + +=item status + +status + +=item rated_price + +rated_price + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new log entry. To add the log entry to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I<hash> method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined +sub table { 'webservice_log'; } + +=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 log entry. 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('webservicelognum') + || $self->ut_foreign_keyn('svcnum', 'cust_svc', 'svcnum') + || $self->ut_foreign_key('custnum', 'cust_main', 'custnum') + || $self->ut_text('method') + || $self->ut_number('quantity') + || $self->ut_numbern('_date') + || $self->ut_alphan('status') + || $self->ut_moneyn('rated_price') + ; + return $error if $error; + + $self->_date(time) unless $self->_date; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L<FS::Record> + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index 9b3067219..4184b9ce6 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -56,6 +56,8 @@ FS/Report.pm FS/Report/FCC_477.pm FS/Report/Table.pm FS/Report/Table/Monthly.pm +FS/Report/Tax/All.pm +FS/Report/Tax/ByName.pm FS/SearchCache.pm FS/UI/Web.pm FS/UID.pm @@ -864,3 +866,11 @@ FS/fiber_olt.pm t/fiber_olt.t FS/olt_site.pm t/olt_site.t +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-cdr-a2billing-import b/FS/bin/freeside-cdr-a2billing-import index a8469e744..aa7fa4a61 100755 --- a/FS/bin/freeside-cdr-a2billing-import +++ b/FS/bin/freeside-cdr-a2billing-import @@ -120,7 +120,13 @@ my $updates = 0; my $row; while ( $row = $sth->fetchrow_hashref ) { - $row->{calledstation} =~ s/^1//; + my $dst = $row->{calledstation}; + my $dst_ip_addr = ''; + if ($dst =~ m[^SIP/(\d+)@(.*)$] ) { + $dst = $1; + $dst_ip_addr = $2; + } + $dst =~ s/^1//; $row->{src} =~ s/^1//; my $cdr = FS::cdr->new ({ uniqueid => $row->{sessionid}, @@ -129,8 +135,10 @@ while ( $row = $sth->fetchrow_hashref ) { enddate => time2str($row->{stoptime}), duration => $row->{sessiontime}, billsec => $row->{real_sessiontime}, - dst => $row->{calledstation}, + dst => $dst, src => $row->{src}, + dst_ip_addr => $dst_ip_addr, + dstchannel => $row->{calledstation}, charged_party => $row->{username}, upstream_rateplanid => $row->{id_tariffplan}, upstream_rateid => $row->{id_ratecard}, # I think? diff --git a/FS/bin/freeside-cdr-conexiant-import b/FS/bin/freeside-cdr-conexiant-import new file mode 100755 index 000000000..f2b469111 --- /dev/null +++ b/FS/bin/freeside-cdr-conexiant-import @@ -0,0 +1,128 @@ +#!/usr/bin/perl + +use strict; + +use Cpanel::JSON::XS; +use Getopt::Long; +use LWP::UserAgent; +use MIME::Base64; +use Net::HTTPS::Any qw(https_post https_get); + +use FS::UID qw(adminsuidsetup); +use FS::Record qw(qsearchs); +use FS::cdr; +use FS::cdr_batch; + +sub usage { +"Usage: +freeside-cdr-conexiant-import -h -u username -p apikey [-v] freesideuser + +Downloads any existing CDR files with the BilledCallsOnly flag and +imports records that have not been imported yet. Silently skips +records that have already been imported. +"; +} + +# should really be using a module for this +`which unzip` or die "can't find unzip executable"; + +my ($username,$password,$verbose); +GetOptions( + "password=s" => \$password, + "username=s" => \$username, + "verbose" => \$verbose, +); + +my $fsuser = $ARGV[-1]; + +die usage() unless $fsuser; + +adminsuidsetup($fsuser); + +my ( $page, $response, %reply_headers ) = https_post( + 'host' => 'api.conexiant.net', + 'port' => '443', + 'path' => '/v1/Cdrs/SearchCdrsDownloads', + 'headers' => { + 'Authorization' => 'Basic ' . MIME::Base64::encode("$username:$password",'') + }, + 'content' => '{}', +); + +die "Bad response from conexiant server: $response" + unless $response =~ /^200/; + +my $result = decode_json($page); + +die "Error from conexiant: " . ($result->{'ErrorInfo'} || 'No error message') + unless $result->{'Success'}; + +my $files = $result->{'Data'}->{'Result'}; + +die "Unexpected results from conexiant, not an array" + unless ref($files) eq 'ARRAY'; + +my $dir = $FS::UID::cache_dir. "/cache.". $FS::UID::datasrc; +my $ua = LWP::UserAgent->new; + +# Download files are created automatically at regular frequent intervals, +# but they contain overlapping data. +# +# FS::cdr::conexiant automatically skips previously imported cdrs +foreach my $file (@$files) { + next unless $file->{'BilledCallsOnly'}; + my $cdrbatch = 'conexiant-' . $file->{'Identifier'}; + # files that contained no new records will unfortunately be re-downloaded, + # but the alternative is to leave an excess of empty batches in system, + # and re-downloading is harmless (all files expire after 48 hours anyway) + if (qsearchs('cdr_batch',{ 'cdrbatch' => $cdrbatch })) { + print "$cdrbatch already imported\n" if $verbose; + next; + } + if ($verbose) { + print "Downloading $cdrbatch\n". + " Created ".$file->{'CreatedOn'}."\n". + " Start ".$file->{'QueryStart'}."\n". + " End ".$file->{'QueryEnd'}."\n". + " Link ".$file->{'ValidLink'}."\n"; + } + my $zfh = new File::Temp( TEMPLATE => 'conexiant.XXXXXXXX', + SUFFIX => '.zip', + DIR => $dir, + ) + or die "can't open temporary file to store download: $!\n"; + my $cfh = new File::Temp( TEMPLATE => 'conexiant.XXXXXXXX', + SUFFIX => '.csv', + DIR => $dir, + ) + or die "can't open temporary file to unzip download: $!\n"; + # yeah, these files ain't secured in any way + my $response = $ua->get($file->{'ValidLink'}, ':content_file' => $zfh->filename); + unless ($response->is_success) { + die "Error downloading $cdrbatch: ".$response->status_line; + } + my $zfilename = $zfh->filename; + print $cfh `unzip -p $zfilename 'Conexiant Cdrs.csv'`; + seek($cfh,0,0); + print "Importing batch $cdrbatch\n" if $verbose; + my $error = FS::cdr::batch_import({ + 'batch_namevalue' => $cdrbatch, + 'file' => $cfh->filename, + 'format' => 'conexiant' + }); + if ($error eq 'Empty file!') { + print "File contains no records\n" if $verbose; + $error = ''; + } elsif ($error eq "All records in file were previously imported") { + print "File contains no new cdrs, no batch created\n" if $verbose; + $error = ''; + } elsif ($verbose && !$error) { + print "File successfully imported\n"; + } + die "Error importing $cdrbatch: $error" if $error; +} + +exit; + + + diff --git a/FS/bin/freeside-cdr-evariste-import b/FS/bin/freeside-cdr-evariste-import index 0487ae539..d5e13f98c 100755 --- a/FS/bin/freeside-cdr-evariste-import +++ b/FS/bin/freeside-cdr-evariste-import @@ -100,7 +100,7 @@ while (my $row = $csth->fetchrow_hashref) { 'cdrbatchnum' => $cdr_batch->cdrbatchnum, 'uniqueid' => $row->{'id'}, 'src' => $row->{'src'}, - 'dst' => $row->{'dest'}, + 'dst' => $row->{'routing_target'} || $row->{'dest'}, # dest_orig? dest_trans? 'startdate' => int(str2time($row->{'start_time'})), 'answerdate' => int(str2time($row->{'answer_time'})), 'enddate' => int(str2time($row->{'end_time'})), diff --git a/FS/bin/freeside-cdrrewrited b/FS/bin/freeside-cdrrewrited index 16f931fbf..34a206849 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 ) @@ -198,6 +219,18 @@ while (1) { } + if ( $conf->exists('cdr-userfield_dnis_rewrite') and + $cdr->userfield =~ /DNIS=(\d+)/ ) { + $cdr->dst($1); + push @status, 'userfield_dnis'; + } + + if ( $conf->exists('cdr-intl_to_domestic_rewrite') and + $cdr->dst =~ /^(011)(\d{0,7})$/ ) { + $cdr->dst($2); + push @status, 'intl_to_domestic'; + } + $cdr->freesiderewritestatus( scalar(@status) ? join('/', @status) : 'skipped' ); @@ -233,6 +266,9 @@ sub _shouldrun { || $conf->exists('cdr-charged_party_rewrite') || $conf->exists('cdr-taqua-accountcode_rewrite') || $conf->exists('cdr-taqua-callerid_rewrite') + || $conf->exists('cdr-intl_to_domestic_rewrite') + || $conf->exists('cdr-userfield_dnis_rewrite') + || $conf->exists('cdr-skip_duplicate_rewrite') || 0 ; } @@ -252,8 +288,49 @@ freeside-cdrrewrited - Real-time daemon for CDR rewriting =head1 DESCRIPTION Runs continuously, searches for CDRs and does forwarded-call rewriting if any -of the "cdr-asterisk_forward_rewrite", "cdr-charged_party_rewrite" or -"cdr-taqua-accountcode_rewrite" config options are enabled. +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 +"other", and tries to assign a cdrtypenum based on that. + +=item cdr-asterisk_forward_rewrite + +Identifies Asterisk forwarded calls using the 'dstchannel' field. If the +dstchannel is "Local/" followed by a number, but the number doesn't match the +dst field, the dst field will be rewritten to match. + +=item cdr-charged_party_rewrite + +Calls set_charged_party on all calls. + +=item cdr-taqua-accountcode_rewrite + +=item cdr-taqua-callerid_rewrite + +These actually have the same effect. Taqua uses cdrtypenum = 1 to tag accessory +records. They will have "sessionnum" = that of the primary record, and +"lastapp" indicating their function: + +- "acctcode": "lastdata" contains the dialed account code. Insert this into the +accountcode field of the primary record. + +- "CallerId": "lastdata" contains "allowed" or "restricted". If "restricted" +then the clid field of the primary record is set to "PRIVATE". + +=item cdr-intl_to_domestic_rewrite + +Finds records where the destination number has the "011" international prefix, +but with seven or fewer digits in the rest of the number, and strips the "011" +prefix so that they will be treated as domestic calls. This is very uncommon. =head1 SEE ALSO diff --git a/FS/bin/freeside-ipifony-download b/FS/bin/freeside-ipifony-download index ee1f4bdfe..10faa7483 100644 --- a/FS/bin/freeside-ipifony-download +++ b/FS/bin/freeside-ipifony-download @@ -13,7 +13,7 @@ use File::Copy qw(copy); use Text::CSV; my %opt; -getopts('vqa:P:C:e:', \%opt); +getopts('vqNa:P:C:e:', \%opt); # Product codes that are subject to flat rate E911 charges. For these # products, the'quantity' field represents the number of lines. @@ -32,6 +32,7 @@ sub HELP_MESSAGE { ' freeside-ipifony-download [ -v ] [ -q ] + [ -N ] [ -a archivedir ] [ -P port ] [ -C category ] @@ -192,7 +193,8 @@ FILE: foreach my $filename (@$files) { if ( $next_bill_date ) { my ($bill_month, $bill_year) = (localtime($next_bill_date))[4, 5]; my ($this_month, $this_year) = (localtime(time))[4, 5]; - if ( $this_month == $bill_month and $this_year == $bill_year ) { + if ( $opt{N} or + $this_month == $bill_month and $this_year == $bill_year ) { $cust_main->set('charge_date', $next_bill_date); } } @@ -296,6 +298,7 @@ freeside-ipifony-download - Download and import invoice items from IPifony. freeside-ipifony-download [ -v ] [ -q ] + [ -N ] [ -a archivedir ] [ -P port ] [ -C category ] @@ -312,12 +315,19 @@ have an authorization key to connect as that user. I<hostname>: the SFTP server. +I<path>: the path on the server to the working directory. The working +directory is the one containing the "ready/" and "done/" subdirectories. + =head1 OPTIONAL PARAMETERS -v: Be verbose. -q: Include the quantity and unit price in the charge description. +-N: Always bill the charges on the customer's next bill date, if they have +one. Otherwise, charges will be billed on the next bill date only if it's +within the current calendar month. + -a I<archivedir>: Save a copy of the downloaded file to I<archivedir>. -P I<port>: Connect to that TCP port. diff --git a/FS/bin/freeside-upgrade b/FS/bin/freeside-upgrade index 9c2811538..77087c373 100755 --- a/FS/bin/freeside-upgrade +++ b/FS/bin/freeside-upgrade @@ -61,76 +61,37 @@ $start = time; my @bugfix = (); -if (dbdef->table('cust_main')->column('agent_custid') && ! $opt_s) { - push @bugfix, - "UPDATE cust_main SET agent_custid = NULL where agent_custid = ''"; - - push @bugfix, - "UPDATE h_cust_main SET agent_custid = NULL where agent_custid = ''" - if (dbdef->table('h_cust_main')); -} - -if ( dbdef->table('cgp_rule_condition') && - dbdef->table('cgp_rule_condition')->column('condition') - ) -{ - push @bugfix, - "ALTER TABLE ${_}cgp_rule_condition RENAME COLUMN condition TO conditionname" - for '', 'h_'; - -} - -if ( dbdef->table('areacode') and - dbdef->table('areacode')->primary_key eq 'code' ) -{ - if ( driver_name =~ /^mysql/i ) { - push @bugfix, - 'ALTER TABLE areacode DROP PRIMARY KEY', - 'ALTER TABLE areacode ADD COLUMN (areanum int auto_increment primary key)'; - } - else { - push @bugfix, 'ALTER TABLE areacode DROP CONSTRAINT areacode_pkey'; - } -} - -if ( dbdef->table('upgrade_journal') ) { - if ( driver_name =~ /^Pg/i ) { - push @bugfix, " - SELECT SETVAL( 'upgrade_journal_upgradenum_seq', - ( SELECT MAX(upgradenum) FROM upgrade_journal ) - ) - "; - #MySQL can't do this in a statement so have to do it manually - #} elsif ( driver_name =~ /^mysql/i ) { - # push @bugfix, " - # ALTER TABLE upgrade_journal AUTO_INCREMENT = - # ( ( SELECT MAX(upgradenum) FROM upgrade_journal ) + 1 ) - # "; - } -} - if ( $DRY_RUN ) { - print - join(";\n", @bugfix ). ";\n"; -} elsif ( @bugfix ) { - + print join(";\n", @bugfix ). ";\n"; +} else { foreach my $statement ( @bugfix ) { warn "$statement\n"; $dbh->do( $statement ) or die "Error: ". $dbh->errstr. "\n executing: $statement"; } +} +### +# Fixes before schema upgrade +### +# this isn't actually the main schema upgrade, this calls _upgrade_schema +# in any class that has it +if ( $DRY_RUN ) { + #XXX no dry run for upgrade_schema stuff yet. + # looking at the code some are a mix of SQL statements and our methods, icky. + # its not like dry run is 100% anyway, all sort of other later upgrade tasks + # aren't printed either +} else { upgrade_schema(%upgrade_opts); dbdef_create($dbh, $dbdef_file); delete $FS::Schema::dbdef_cache{$dbdef_file}; #force an actual reload reload_dbdef($dbdef_file); - } -#you should have run fs-migrate-part_svc ages ago, when you upgraded -#from 1.3 to 1.4... if not, it needs to be hooked into -upgrade here or -#you'll lose all the part_svc settings it migrates to part_svc_column +### +# Now here is the main/automatic schema upgrade via DBIx::DBSchema +### my $conf = new FS::Conf; @@ -144,10 +105,12 @@ my @statements = dbdef->sql_update_schema( $dbdef_dist, { 'nullify_default' => 1, }, ); -#### NEW CUSTOM FIELDS: +### +# New custom fields +### # 1. prevent new custom field columns from being dropped by upgrade # 2. migrate old virtual fields to real fields (new custom fields) -#### + my $cfsth = $dbh->prepare("SELECT * FROM part_virtual_field") or die $dbh->errstr; $cfsth->execute or die $cfsth->errstr; @@ -168,6 +131,10 @@ while ( $cf = $cfsth->fetchrow_hashref ) { } warn "Custom fields schema upgrade completed"; +### +# Other stuff +### + @statements = grep { $_ !~ /^CREATE +INDEX +h_queue/i } #useless, holds up queue insertion @statements; @@ -194,7 +161,10 @@ if ( $opt_c ) { } -my $MAX_HANDLES; # undef for now, set it if you want a limit + +### +# Now run the @statements +### if ( $DRY_RUN ) { print @@ -202,6 +172,12 @@ if ( $DRY_RUN ) { exit; } elsif ( $opt_a ) { + ### + # -a: Run schema changes in parallel (Pg only). + ### + + my $MAX_HANDLES; # undef for now, set it if you want a limit + my @phases = map { [] } 0..4; my $fsupgrade_idx = 1; my %idx_map; @@ -293,7 +269,13 @@ if ( $DRY_RUN ) { # $start = time; # dbdef->update_schema( dbdef_dist(datasrc), $dbh ); -} else { # normal case, run statements sequentially + +} else { + + ### + # normal case, run statements sequentially + ### + foreach my $statement ( @statements ) { warn "$statement\n"; $dbh->do( $statement ) diff --git a/FS/t/access_user_page_pref.t b/FS/t/access_user_page_pref.t new file mode 100644 index 000000000..4a45b4d39 --- /dev/null +++ b/FS/t/access_user_page_pref.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::access_user_page_pref; +$loaded=1; +print "ok 1\n"; 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"; diff --git a/FS/t/suite/00-new_customer.t b/FS/t/suite/00-new_customer.t new file mode 100755 index 000000000..8e86459d1 --- /dev/null +++ b/FS/t/suite/00-new_customer.t @@ -0,0 +1,67 @@ +#!/usr/bin/perl + +use FS::Test; +use Test::More tests => 4; + +my $FS = FS::Test->new; +# get the form +$FS->post('/edit/cust_main.cgi'); +my $form = $FS->form('CustomerForm'); + +my %params = ( + residential_commercial => 'Residential', + agentnum => 1, + refnum => 1, + last => 'Customer', + first => 'New', + invoice_email => 'newcustomer@fake.freeside.biz', + bill_address1 => '123 Example Street', + bill_address2 => 'Apt. Z', + bill_city => 'Sacramento', + bill_state => 'CA', + bill_zip => '94901', + bill_country => 'US', + bill_coord_auto => 'Y', + daytime => '916-555-0100', + night => '916-555-0200', + ship_address1 => '125 Example Street', + ship_address2 => '3rd Floor', + ship_city => 'Sacramento', + ship_state => 'CA', + ship_zip => '94901', + ship_country => 'US', + ship_coord_auto => 'Y', + invoice_ship_address => 'Y', + postal_invoice => 'Y', + billday => '1', + no_credit_limit => 1, + # payment method + custpaybynum0_payby => 'CARD', + custpaybynum0_payinfo => '4012888888881881', + custpaybynum0_paydate_month => '12', + custpaybynum0_paydate_year => '2020', + custpaybynum0_paycvv => '123', + custpaybynum0_payname => '', + custpaybynum0_weight => 1, +); +foreach (keys %params) { + $form->value($_, $params{$_}); +} +$FS->post($form); +ok( $FS->error eq '' , 'form posted' ); +if ( + ok($FS->redirect =~ m[^/view/cust_main.cgi\?(\d+)], 'new customer accepted') +) { + my $custnum = $1; + my $cust = $FS->qsearchs('cust_main', { custnum => $1 }); + isa_ok ( $cust, 'FS::cust_main' ); + $FS->post($FS->redirect); + ok ( $FS->error eq '' , 'can view customer' ); +} else { + # try to display the error message, or if not, show everything + $FS->post($FS->redirect); + diag ($FS->error); + done_testing(2); +} + +1; diff --git a/FS/t/suite/01-order_pkg.t b/FS/t/suite/01-order_pkg.t new file mode 100755 index 000000000..ab5a2ddc6 --- /dev/null +++ b/FS/t/suite/01-order_pkg.t @@ -0,0 +1,49 @@ +#!/usr/bin/perl + +use Test::More tests => 4; +use FS::Test; +use Date::Parse 'str2time'; +my $FS = FS::Test->new; + +# get the form +$FS->post('/misc/order_pkg.html', custnum => 2); +my $form = $FS->form('OrderPkgForm'); + +# Customer #2 has three packages: +# a $30 monthly prorate, a $90 monthly prorate, and a $25 annual prorate. +# Next bill date on the monthly prorates is 2016-04-01. +# Add a new package that will start billing on 2016-03-20 (to make prorate +# behavior visible). + +my %params = ( + pkgpart => 2, + quantity => 1, + start => 'on_date', + start_date => '03/20/2016', + package_comment0 => $0, # record the test we're executing +); + +$form->find_input('start')->disabled(0); # JS +foreach (keys %params) { + $form->value($_, $params{$_}); +} +$FS->post($form); +ok( $FS->error eq '' , 'form posted' ); +if ( + ok( $FS->page =~ m[location = '.*/view/cust_main.cgi.*\#cust_pkg(\d+)'], + 'new package accepted' ) +) { + # on success, sends us back to cust_main view with #cust_pkg$pkgnum + # but with an in-page javascript redirect + my $pkg = $FS->qsearchs('cust_pkg', { pkgnum => $1 }); + isa_ok( $pkg, 'FS::cust_pkg' ); + ok($pkg->start_date == str2time('2016-03-20'), 'start date set'); +} else { + # try to display the error message, or if not, show everything + $FS->post($FS->redirect); + diag ($FS->error); + done_testing(2); +} + +1; + diff --git a/FS/t/suite/02-bill_customer.t b/FS/t/suite/02-bill_customer.t new file mode 100755 index 000000000..3fa908e96 --- /dev/null +++ b/FS/t/suite/02-bill_customer.t @@ -0,0 +1,38 @@ +#!/usr/bin/perl + +use FS::Test; +use Test::More tests => 6; +use Test::MockTime 'set_fixed_time'; +use Date::Parse 'str2time'; +use FS::cust_main; + +my $FS = FS::Test->new; + +# After test 01: cust#2 has a package set to bill on 2016-03-20. +# Set local time. +my $date = '2016-03-20'; +set_fixed_time(str2time($date)); +my $cust_main = FS::cust_main->by_key(2); +my @return; + +# Bill the customer. +my $error = $cust_main->bill( return_bill => \@return ); +ok($error eq '', "billed on $date") or diag($error); + +# should be an invoice now +my $cust_bill = $return[0]; +isa_ok($cust_bill, 'FS::cust_bill'); + +# Apr 1 - Mar 20 = 12 days = 288 hours +# Apr 1 - Mar 1 = 31 days - 1 hour (DST) = 743 hours +# 288/743 * $30 = $11.63 recur + $20.00 setup +ok( $cust_bill->charged == 31.63, 'prorated first month correctly' ); + +# the package bill date should now be 2016-04-01 +my @lineitems = $cust_bill->cust_bill_pkg; +ok( scalar(@lineitems) == 1, 'one package was billed' ); +my $pkg = $lineitems[0]->cust_pkg; +ok( $pkg->status eq 'active', 'package is now active' ); +ok( $pkg->bill == str2time('2016-04-01'), 'package bill date set correctly' ); + +1; diff --git a/FS/t/suite/03-realtime_pay.t b/FS/t/suite/03-realtime_pay.t new file mode 100755 index 000000000..17456bb15 --- /dev/null +++ b/FS/t/suite/03-realtime_pay.t @@ -0,0 +1,40 @@ +#!/usr/bin/perl + +use FS::Test; +use Test::More tests => 2; +use FS::cust_main; + +my $FS = FS::Test->new; + +# In the stock database, cust#5 has open invoices +my $cust_main = FS::cust_main->by_key(5); +my $balance = $cust_main->balance; +ok( $balance > 10.00, 'customer has an outstanding balance of more than $10.00' ); + +# Get the payment form +$FS->post('/misc/payment.cgi?payby=CARD;custnum=5'); +my $form = $FS->form('OneTrueForm'); +$form->value('amount' => '10.00'); +$form->value('custpaybynum' => ''); +$form->value('payinfo' => '4012888888881881'); +$form->value('month' => '01'); +$form->value('year' => '2020'); +# payname and location fields should already be set +$form->value('save' => 1); +$form->value('auto' => 1); +$FS->post($form); + +# on success, gives a redirect to the payment receipt +my $paynum; +if ($FS->redirect =~ m[^/view/cust_pay.html\?(\d+)]) { + pass('payment processed'); + $paynum = $1; +} elsif ( $FS->error ) { + fail('payment rejected'); + diag ( $FS->error ); +} else { + fail('unknown result'); + diag ( $FS->page ); +} + +1; diff --git a/FS/t/suite/04-pkg_change_status.t b/FS/t/suite/04-pkg_change_status.t new file mode 100755 index 000000000..cc969983a --- /dev/null +++ b/FS/t/suite/04-pkg_change_status.t @@ -0,0 +1,103 @@ +#!/usr/bin/perl + +=head2 DESCRIPTION + +Tests the effect of a scheduled change on the status of an active or +suspended package. Ref RT#38564. + +Correct: A scheduled package change should result in a package with the same +status as before. + +=cut + +use strict; +use Test::More tests => 20; +use FS::Test; +use Date::Parse 'str2time'; +use Test::MockTime qw(set_fixed_time); +use FS::cust_main; +use FS::cust_pkg; +my $FS = FS::Test->new; + +# Create two package defs with the suspend_bill flag, and one with +# the unused_credit_change flag. +my $part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 2 }); +my $error; +my @part_pkgs; +foreach my $i (0, 1) { + $part_pkgs[$i] = $part_pkg->clone; + $part_pkgs[$i]->insert(options => { $part_pkg->options, + 'suspend_bill' => 1, + 'unused_credit_change' => $i } ); + BAIL_OUT("can't configure package: $error") if $error; +} + +# For customer #3, order four packages. 0-1 will be suspended, 2-3 will not. +# 1 and 3 will use $part_pkgs[1], the one with unused_credit_change. + +my $cust = $FS->qsearchs('cust_main', { custnum => 3 }); +my @pkgs; +foreach my $i (0..3) { + $pkgs[$i] = FS::cust_pkg->new({ pkgpart => $part_pkgs[$i % 2]->pkgpart }); + $error = $cust->order_pkg({ cust_pkg => $pkgs[$i] }); + BAIL_OUT("can't order package: $error") if $error; +} + +# On Mar 25, bill the customer. + +set_fixed_time(str2time('2016-03-25')); +$error = $cust->bill_and_collect; +ok( $error eq '', 'initially bill customer' ); +# update our @pkgs to match +@pkgs = map { $_->replace_old } @pkgs; + +# On Mar 26, suspend packages 0-1. + +set_fixed_time(str2time('2016-03-25')); +my $reason_type = $FS->qsearchs('reason_type', { type => 'Suspend Reason' }); +foreach my $i (0,1) { + $error = $pkgs[$i]->suspend(reason => { + typenum => $reason_type->typenum, + reason => 'Test suspension + future package change', + }); + ok( $error eq '', "suspended package $i" ) or diag($error); + $pkgs[$i] = $pkgs[$i]->replace_old; +} + +# For each of these packages, clone the package def, then schedule a future +# change (on Mar 26) to that package. +my $change_date = str2time('2016-03-26'); +my @new_pkgs; +foreach my $i (0..3) { + my $pkg = $pkgs[$i]; + my $new_part_pkg = $pkg->part_pkg->clone; + $error = $new_part_pkg->insert( options => { $pkg->part_pkg->options } ); + ok( $error eq '', 'created new package def' ) or diag($error); + $error = $pkg->change_later( + pkgpart => $new_part_pkg->pkgpart, + start_date => $change_date, + ); + ok( $error eq '', 'scheduled package change' ) or diag($error); + $new_pkgs[$i] = $FS->qsearchs('cust_pkg', { + pkgnum => $pkg->change_to_pkgnum + }); + ok( $new_pkgs[$i], 'future package was created' ); +} + +# Then bill the customer on that date. +set_fixed_time($change_date); +$error = $cust->bill_and_collect; +ok( $error eq '', 'billed customer on change date' ) or diag($error); + +foreach my $i (0,1) { + $new_pkgs[$i] = $new_pkgs[$i]->replace_old; + ok( $new_pkgs[$i]->status eq 'suspended', "new package $i is suspended" ) + or diag($new_pkgs[$i]->status); +} +foreach my $i (2,3) { + $new_pkgs[$i] = $new_pkgs[$i]->replace_old; + ok( $new_pkgs[$i]->status eq 'active', "new package $i is active" ) + or diag($new_pkgs[$i]->status); +} + +1; diff --git a/FS/t/suite/05-prorate_sync_same_day.t b/FS/t/suite/05-prorate_sync_same_day.t new file mode 100755 index 000000000..91a8efa74 --- /dev/null +++ b/FS/t/suite/05-prorate_sync_same_day.t @@ -0,0 +1,97 @@ +#!/usr/bin/perl + +=head2 DESCRIPTION + +Tests the effect of ordering and activating two sync_bill_date packages on +the same day. Ref RT#42108. + +Correct: If the packages have prorate_round_day = 1 (round nearest), or 3 +(round down) then the second package should be prorated one day short. If +they have prorate_round_day = 2 (round up), they should be billed +for the same amount. In both cases they should have the same next bill date. + +=cut + +use strict; +use Test::More tests => 9; +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; + +foreach my $prorate_mode (1, 2, 3) { + diag("prorate_round_day = $prorate_mode"); + # Create a package def with the sync_bill_date option. + my $error; + my $old_part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 5 }); + my $part_pkg = $old_part_pkg->clone; + BAIL_OUT("existing pkgpart 5 is not a flat monthly package") + unless $part_pkg->freq eq '1' and $part_pkg->plan eq 'flat'; + $error = $part_pkg->insert( + options => { $old_part_pkg->options, + 'sync_bill_date' => 1, + 'prorate_round_day' => $prorate_mode, } + ); + + BAIL_OUT("can't configure package: $error") if $error; + + my $pkgpart = $part_pkg->pkgpart; + # Create a clean customer with no other packages. + 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 => 'Sync bill date', + invoice_email => 'newcustomer@fake.freeside.biz', + bill_location => $location, + ship_location => $location, + }); + $error = $cust->insert; + BAIL_OUT("can't create test customer: $error") if $error; + + my @pkgs; + # Create and bill the first package. + set_fixed_time(str2time('2016-03-10 08:00')); + $pkgs[0] = FS::cust_pkg->new({ pkgpart => $pkgpart }); + $error = $cust->order_pkg({ 'cust_pkg' => $pkgs[0] }); + BAIL_OUT("can't order package: $error") if $error; + $error = $cust->bill_and_collect; + # Check the amount billed. + my ($cust_bill_pkg) = $pkgs[0]->cust_bill_pkg; + my $recur = $part_pkg->base_recur; + ok( $cust_bill_pkg->recur == $recur, "first package recur is $recur" ) + or diag("first package recur is ".$cust_bill_pkg->recur); + + # Create and bill the second package. + set_fixed_time(str2time('2016-03-10 16:00')); + $pkgs[1] = FS::cust_pkg->new({ pkgpart => $pkgpart }); + $error = $cust->order_pkg({ 'cust_pkg' => $pkgs[1] }); + BAIL_OUT("can't order package: $error") if $error; + $error = $cust->bill_and_collect; + + # Check the amount billed. + if ( $prorate_mode == 1 or $prorate_mode == 3 ) { + # it should be one day short, in March + $recur = sprintf('%.2f', $recur * 30/31); + } + ($cust_bill_pkg) = $pkgs[1]->cust_bill_pkg; + ok( $cust_bill_pkg->recur == $recur, "second package recur is $recur" ) + or diag("second package recur is ".$cust_bill_pkg->recur); + + my @next_bill = map { time2str('%Y-%m-%d', $_->replace_old->get('bill')) } @pkgs; + + ok( $next_bill[0] eq $next_bill[1], + "both packages will bill again on $next_bill[0]" ) + or diag("first package bill date is $next_bill[0], second package is $next_bill[1]"); +} 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/t/suite/08-sales_tax.t b/FS/t/suite/08-sales_tax.t new file mode 100755 index 000000000..bf1ae48c8 --- /dev/null +++ b/FS/t/suite/08-sales_tax.t @@ -0,0 +1,76 @@ +#!/usr/bin/perl + +=head2 DESCRIPTION + +Tests basic sales tax calculations, including consolidation and rounding. +The invoice will have two charges that add up to $50 and two taxes: +- Tax 1, 8.25%, for $4.125 in tax, which will round up. +- Tax 2, 8.245%, for $4.1225 in tax, which will round down. + +Correct: The invoice will have one line item for each of those taxes, with +the correct amount. + +=cut + +use strict; +use Test::More tests => 2; +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; + +# test configuration +my @taxes = ( + [ 'Tax 1', 8.250, 4.13 ], + [ 'Tax 2', 8.245, 4.12 ], +); + +# Create the customer and charge them +my $cust = $FS->new_customer('Basic taxes'); +$cust->bill_location->state('AZ'); # move it away from the default of CA +my $error; +$error = $cust->insert; +BAIL_OUT("can't create test customer: $error") if $error; +$error = $cust->charge( { + amount => 25.00, + pkg => 'Test charge 1', +} ) || +$cust->charge({ + amount => 25.00, + pkg => 'Test charge 2', +}); +BAIL_OUT("can't create test charges: $error") if $error; + +# Create tax defs +foreach my $tax (@taxes) { + my $cust_main_county = FS::cust_main_county->new({ + 'country' => 'US', + 'state' => 'AZ', + 'exempt_amount' => 0.00, + 'taxname' => $tax->[0], + 'tax' => $tax->[1], + }); + $error = $cust_main_county->insert; + BAIL_OUT("can't create tax definitions: $error") if $error; +} + +# Bill the customer +set_fixed_time(str2time('2016-03-10 08:00')); +my @return; +$error = $cust->bill( return_bill => \@return ); +BAIL_OUT("can't bill charges: $error") if $error; +my $cust_bill = $return[0] or BAIL_OUT("no invoice generated"); +# Check amounts +diag("Tax on 25.00 + 25.00"); +foreach my $cust_bill_pkg ($cust_bill->cust_bill_pkg) { + next if $cust_bill_pkg->pkgnum; + my ($tax) = grep { $_->[0] eq $cust_bill_pkg->itemdesc } @taxes; + if ( $tax ) { + ok ( $cust_bill_pkg->setup eq $tax->[2], "Tax at rate $tax->[1]% = $tax->[2]") + or diag("is ". $cust_bill_pkg->setup); + } +} diff --git a/FS/t/suite/WRITING b/FS/t/suite/WRITING new file mode 100644 index 000000000..d9421cc7b --- /dev/null +++ b/FS/t/suite/WRITING @@ -0,0 +1,93 @@ +WRITING TESTS + +Load the test database (kept in FS-Test/share/test.sql for now). This has +a large set of customers in a known initial state. You can login through +the web interface as "admin"/"admin" to examine the state of things and plan +your test. + +The test scripts now have access to BOTH sides of the web interface, so you +can create an object through the UI and then examine its internal +properties, etc. + + use Test::More tests => 1; + use FS::Test; + my $FS = FS::Test->new; + +$FS has qsearch and qsearchs methods for finding objects directly. You can +do anything with those objects that Freeside backend code could normally do. +For example, this will bill a customer: + + my $cust = $FS->qsearchs('cust_main', { custnum => 52 }); + my $error = $cust->bill; + +TESTING UI INTERACTION + +To fetch a page from the UI, use the post() method: + + $FS->post('/view/cust_main.cgi?52'); + ok( $FS->error eq '', 'fetched customer view' ) or diag($FS->error); + ok( $FS->page =~ /Customer, New/, 'customer is named "Customer, New"' ); + +To simulate a user filling in and submitting a form, first fetch the form, +and select it by name: + + $FS->post('/edit/svc_acct.cgi?98'); + my $form = $FS->form('OneTrueForm'); + +then fill it in and submit it: + + $form->value('clear_password', '1234abcd'); + $FS->post($form); + +and examine the result: + + my $svc_acct = $FS->qsearch('svc_acct', { svcnum => 98 }); + ok( $svc_acct->_password eq '1234abcd', 'password was changed' ); + +TESTING UI FLOW (EDIT/PROCESS/VIEW SEQUENCE) + +Forms for editing records will post to a processing page. $FS->post($form) +handles this. The processing page will usually redirect back to the view +page on success, and back to the edit form with an error on failure. +Determine which kind of redirect it is. If it's a redirect to the edit form, +you need to follow it to report the error. + + if ( $FS->redirect =~ m[^/view/svc_acct.cgi] ) { + + pass('redirected to view page'); + + } elsif ( $FS->redirect =~ m[^/edit/svc_acct.cgi] ) { + + fail('redirected back to edit form'); + $FS->post($FS->redirect); + diag($FS->error); + + } else { + + fail('unsure what happened'); + diag($FS->page); + + } + +RUNNING TESTS AT A SPECIFIC DATE + +Important for testing package billing. Test::MockTime provides the +set_fixed_time() function, which will freeze the time returned by the time() +function at a specific value. I recommend giving it a unix timestamp rather +than a date string to avoid any confusion about time zones. + +Note that FS::Cron::bill and some other parts of the system look at the $^T +variable (the time that the current program started running). You can +override that by just assigning to the variable. + +Customers in the test database are billed up through Mar 1 2016. This will +bill a customer for the next month after that: + + use Test::MockTime qw(set_fixed_time); + use Date::Parse qw(str2time); + + my $cust = $FS->qsearchs('cust_main', { custnum => 52 }); + set_fixed_time( str2time('2016-04-01') ); + $cust->bill; + + diff --git a/FS/t/webservice_log.t b/FS/t/webservice_log.t new file mode 100644 index 000000000..8fc9e94e7 --- /dev/null +++ b/FS/t/webservice_log.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::webservice_log; +$loaded=1; +print "ok 1\n"; |
