X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=038577a9b0844c12eb37cf49b1bf16eef3451435;hb=c8436a358075a901c8d7b3d47919de8c4d6f6f46;hp=d4909167174f7f3fdd19da00a8fda3f3ad55fb3f;hpb=3b35ccbf226efe00c94f3a72dd1c7ed64d926a7c;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index d49091671..038577a9b 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -9,25 +9,26 @@ use Safe; use Carp; use Exporter; use Scalar::Util qw( blessed ); -use Time::Local qw(timelocal_nocheck); +use Time::Local qw(timelocal); use Data::Dumper; use Tie::IxHash; use Digest::MD5 qw(md5_base64); use Date::Format; -use Date::Parse; #use Date::Manip; +use File::Temp qw( tempfile ); use String::Approx qw(amatch); use Business::CreditCard 0.28; use Locale::Country; -use Data::Dumper; use FS::UID qw( getotaker dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef ); -use FS::Misc qw( send_email generate_ps do_print ); +use FS::Misc qw( generate_email send_email generate_ps do_print ); use FS::Msgcat qw(gettext); use FS::cust_pkg; use FS::cust_svc; use FS::cust_bill; use FS::cust_bill_pkg; +use FS::cust_bill_pkg_display; +use FS::cust_bill_pkg_tax_location; use FS::cust_pay; use FS::cust_pay_pending; use FS::cust_pay_void; @@ -36,6 +37,10 @@ use FS::cust_credit; use FS::cust_refund; use FS::part_referral; use FS::cust_main_county; +use FS::cust_location; +use FS::tax_rate; +use FS::cust_tax_location; +use FS::part_pkg_taxrate; use FS::agent; use FS::cust_main_invoice; use FS::cust_credit_bill; @@ -53,7 +58,7 @@ use FS::banned_pay; use FS::payinfo_Mixin; use FS::TicketSystem; -@ISA = qw( FS::Record FS::payinfo_Mixin ); +@ISA = qw( FS::payinfo_Mixin FS::Record ); @EXPORT_OK = qw( smart_search ); @@ -132,97 +137,181 @@ FS::Record. The following fields are currently supported: =over 4 -=item custnum - primary key (assigned automatically for new customers) +=item custnum + +Primary key (assigned automatically for new customers) + +=item agentnum + +Agent (see L) + +=item refnum + +Advertising source (see L) + +=item first + +First name -=item agentnum - agent (see L) +=item last -=item refnum - Advertising source (see L) +Last name -=item first - name +=item ss -=item last - name +Cocial security number (optional) -=item ss - social security number (optional) +=item company -=item company - (optional) +(optional) =item address1 -=item address2 - (optional) +=item address2 + +(optional) =item city -=item county - (optional, see L) +=item county + +(optional, see L) -=item state - (see L) +=item state + +(see L) =item zip -=item country - (see L) +=item country + +(see L) + +=item daytime + +phone (optional) + +=item night + +phone (optional) -=item daytime - phone (optional) +=item fax -=item night - phone (optional) +phone (optional) -=item fax - phone (optional) +=item ship_first -=item ship_first - name +Shipping first name -=item ship_last - name +=item ship_last -=item ship_company - (optional) +Shipping last name + +=item ship_company + +(optional) =item ship_address1 -=item ship_address2 - (optional) +=item ship_address2 + +(optional) =item ship_city -=item ship_county - (optional, see L) +=item ship_county + +(optional, see L) -=item ship_state - (see L) +=item ship_state + +(see L) =item ship_zip -=item ship_country - (see L) +=item ship_country + +(see L) + +=item ship_daytime + +phone (optional) -=item ship_daytime - phone (optional) +=item ship_night -=item ship_night - phone (optional) +phone (optional) -=item ship_fax - phone (optional) +=item ship_fax -=item payby - Payment Type (See L for valid payby values) +phone (optional) -=item payinfo - Payment Information (See L for data format) +=item payby + +Payment Type (See L for valid payby values) + +=item payinfo + +Payment Information (See L for data format) -=item paymask - Masked payinfo (See L for how this works) +=item paymask + +Masked payinfo (See L for how this works) =item paycvv Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card -=item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy +=item paydate + +Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy + +=item paystart_month + +Start date month (maestro/solo cards only) -=item paystart_month - start date month (maestro/solo cards only) +=item paystart_year -=item paystart_year - start date year (maestro/solo cards only) +Start date year (maestro/solo cards only) -=item payissue - issue number (maestro/solo cards only) +=item payissue -=item payname - name on card or billing name +Issue number (maestro/solo cards only) -=item payip - IP address from which payment information was received +=item payname -=item tax - tax exempt, empty or `Y' +Name on card or billing name -=item otaker - order taker (assigned automatically, see L) +=item payip -=item comments - comments (optional) +IP address from which payment information was received -=item referral_custnum - referring customer number +=item tax -=item spool_cdr - Enable individual CDR spooling, empty or `Y' +Tax exempt, empty or `Y' + +=item otaker + +Order taker (assigned automatically, see L) + +=item comments + +Comments (optional) + +=item referral_custnum + +Referring customer number + +=item spool_cdr + +Enable individual CDR spooling, empty or `Y' + +=item dundate + +A suggestion to events (see L) to delay until this unix timestamp + +=item squelch_cdr + +Discourage individual CDR printing, empty or `Y' =back @@ -336,6 +425,9 @@ sub insert { $self->signupdate(time) unless $self->signupdate; + $self->auto_agent_custid() + if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid; + my $error = $self->SUPER::insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -412,6 +504,35 @@ sub insert { } +use File::CounterFile; +sub auto_agent_custid { + my $self = shift; + + my $format = $conf->config('cust_main-auto_agent_custid'); + my $agent_custid; + if ( $format eq '1YMMXXXXXXXX' ) { + + my $counter = new File::CounterFile 'cust_main.agent_custid'; + $counter->lock; + + my $ym = 100000000000 + time2str('%y%m00000000', time); + if ( $ym > $counter->value ) { + $counter->{'value'} = $agent_custid = $ym; + $counter->{'updated'} = 1; + } else { + $agent_custid = $counter->inc; + } + + $counter->unlock; + + } else { + die "Unknown cust_main-auto_agent_custid format: $format"; + } + + $self->agent_custid($agent_custid); + +} + sub start_copy_skel { my $self = shift; @@ -539,12 +660,115 @@ sub _copy_skel { } +=item order_pkg HASHREF | OPTION => VALUE ... + +Orders a single package. + +Options may be passed as a list of key/value pairs or as a hash reference. +Options are: + +=over 4 + +=item cust_pkg + +FS::cust_pkg object + +=item cust_location + +Optional FS::cust_location object + +=item svcs + +Optional arryaref of FS::svc_* service objects. + +=item depend_jobnum + +If this option is set to a job queue jobnum (see L), all provisioning +jobs will have a dependancy on the supplied job (they will not run until the +specific job completes). This can be used to defer provisioning until some +action completes (such as running the customer's credit card successfully). + +=back + +=cut + +sub order_pkg { + my $self = shift; + my $opt = ref($_[0]) ? shift : { @_ }; + + warn "$me order_pkg called with options ". + join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n" + if $DEBUG; + + my $cust_pkg = $opt->{'cust_pkg'}; + my $seconds = $opt->{'seconds'}; + my $svcs = $opt->{'svcs'} || []; + + my %svc_options = (); + $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'} + if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'}; + + 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; + + if ( $opt->{'cust_location'} && + ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) { + my $error = $opt->{'cust_location'}->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_location (transaction rolled back): $error"; + } + $cust_pkg->locationnum($opt->{'cust_location'}->locationnum); + } + + $cust_pkg->custnum( $self->custnum ); + + my $error = $cust_pkg->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_pkg (transaction rolled back): $error"; + } + + foreach my $svc_something ( @{ $opt->{'svcs'} } ) { + if ( $svc_something->svcnum ) { + my $old_cust_svc = $svc_something->cust_svc; + my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash }; + $new_cust_svc->pkgnum( $cust_pkg->pkgnum); + $error = $new_cust_svc->replace($old_cust_svc); + } else { + $svc_something->pkgnum( $cust_pkg->pkgnum ); + if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) { + $svc_something->seconds( $svc_something->seconds + $$seconds ); + $$seconds = 0; + } + $error = $svc_something->insert(%svc_options); + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting svc_ (transaction rolled back): $error"; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error + +} + =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ] -Like the insert method on an existing record, this method orders a package -and included services atomicaly. Pass a Tie::RefHash data structure to this -method containing FS::cust_pkg and FS::svc_I objects. There should -be a better explanation of this, but until then, here's an example: +Like the insert method on an existing record, this method orders multiple +packages and included services atomicaly. Pass a Tie::RefHash data structure +to this method containing FS::cust_pkg and FS::svc_I objects. +There should be a better explanation of this, but until then, here's an +example: use Tie::RefHash; tie %hash, 'Tie::RefHash'; #this part is important @@ -577,9 +801,7 @@ sub order_pkgs { my $cust_pkgs = shift; my $seconds = shift; my %options = @_; - my %svc_options = (); - $svc_options{'depend_jobnum'} = $options{'depend_jobnum'} - if exists $options{'depend_jobnum'}; + warn "$me order_pkgs called with options ". join(', ', map { "$_: $options{$_}" } keys %options ). "\n" if $DEBUG; @@ -598,32 +820,17 @@ sub order_pkgs { local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'}; foreach my $cust_pkg ( keys %$cust_pkgs ) { - $cust_pkg->custnum( $self->custnum ); - my $error = $cust_pkg->insert; + + my $error = $self->order_pkg( 'cust_pkg' => $cust_pkg, + 'svcs' => $cust_pkgs->{$cust_pkg}, + 'seconds' => $seconds, + 'depend_jobnum' => $options{'depend_jobnum'}, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "inserting cust_pkg (transaction rolled back): $error"; - } - foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) { - if ( $svc_something->svcnum ) { - my $old_cust_svc = $svc_something->cust_svc; - my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash }; - $new_cust_svc->pkgnum( $cust_pkg->pkgnum); - $error = $new_cust_svc->replace($old_cust_svc); - } else { - $svc_something->pkgnum( $cust_pkg->pkgnum ); - if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) { - $svc_something->seconds( $svc_something->seconds + $$seconds ); - $$seconds = 0; - } - $error = $svc_something->insert(%svc_options); - } - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - #return "inserting svc_ (transaction rolled back): $error"; - return $error; - } + return $error; } + } $dbh->commit or die $dbh->errstr if $oldAutoCommit; @@ -1208,6 +1415,7 @@ sub check { || $self->ut_number('agentnum') || $self->ut_textn('agent_custid') || $self->ut_number('refnum') + || $self->ut_textn('custbatch') || $self->ut_name('last') || $self->ut_name('first') || $self->ut_snumbern('birthdate') @@ -1224,7 +1432,9 @@ sub check { || $self->ut_textn('stateid') || $self->ut_textn('stateid_state') || $self->ut_textn('invoice_terms') + || $self->ut_alphan('geocode') ; + #barf. need message catalogs. i18n. etc. $error .= "Please select an advertising source." if $error =~ /^Illegal or empty \(numeric\) refnum: /; @@ -1531,7 +1741,7 @@ sub check { $self->payname($1); } - foreach my $flag (qw( tax spool_cdr )) { + foreach my $flag (qw( tax spool_cdr squelch_cdr )) { $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag(); $self->$flag($1); } @@ -1599,6 +1809,17 @@ sub cust_pkg { shift->all_pkgs(@_); } +=item cust_location + +Returns all locations (see L) for this customer. + +=cut + +sub cust_location { + my $self = shift; + qsearch('cust_location', { 'custnum' => $self->custnum } ); +} + =item ncancelled_pkgs Returns all non-cancelled packages (see L) for this customer. @@ -1956,7 +2177,12 @@ sub bill_and_collect { $self->ncancelled_pkgs; foreach my $cust_pkg ( @cancel_pkgs ) { - my $error = $cust_pkg->cancel; + my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); + my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, + 'reason_otaker' => $cpr->otaker + ) + : () + ); warn "Error cancelling expired pkg ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" if $error; @@ -1982,7 +2208,14 @@ sub bill_and_collect { $self->ncancelled_pkgs; foreach my $cust_pkg ( @susp_pkgs ) { - my $error = $cust_pkg->suspend; + my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn') + if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T); + my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum, + 'reason_otaker' => $cpr->otaker + ) + : () + ); + warn "Error suspending package ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" if $error; @@ -2046,6 +2279,7 @@ sub bill { if $DEBUG; my $time = $options{'time'} || time; + my $invoice_time = $options{'invoice_time'} || $time; #put below somehow? local $SIG{HUP} = 'IGNORE'; @@ -2068,15 +2302,12 @@ sub bill { # & generate invoice database. ### - my( $total_setup, $total_recur ) = ( 0, 0 ); - my %tax; + my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 ); my %taxlisthash; - my %taxname; my @precommit_hooks = (); - foreach my $cust_pkg ( - qsearch('cust_pkg', { 'custnum' => $self->custnum } ) - ) { + my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } ); + foreach my $cust_pkg (@cust_pkgs) { #NO!! next if $cust_pkg->cancel; next if $cust_pkg->getfield('cancel'); @@ -2091,270 +2322,27 @@ sub bill { my $real_pkgpart = $cust_pkg->pkgpart; my %hash = $cust_pkg->hash; - my $old_cust_pkg = new FS::cust_pkg \%hash; foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) { - $cust_pkg->pkgpart($part_pkg->pkgpart); - $cust_pkg->set($_, $hash{$_}) foreach qw( setup last_bill bill ); - - my @details = (); - - my $lineitems = 0; - - ### - # bill setup - ### - - my $setup = 0; - if ( ! $cust_pkg->setup && - ( - ( $conf->exists('disable_setup_suspended_pkgs') && - ! $cust_pkg->getfield('susp') - ) || ! $conf->exists('disable_setup_suspended_pkgs') - ) - || $options{'resetup'} - ) { - - warn " bill setup\n" if $DEBUG > 1; - $lineitems++; - - $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; - if ( $@ ) { - $dbh->rollback if $oldAutoCommit; - return "$@ running calc_setup for $cust_pkg\n"; - } - - $cust_pkg->setfield('setup', $time) - unless $cust_pkg->setup; - #do need it, but it won't get written to the db - #|| $cust_pkg->pkgpart != $real_pkgpart; - - } - - ### - # bill recurring fee - ### - - my $recur = 0; - my $sdate; - if ( $part_pkg->getfield('freq') ne '0' && - ! $cust_pkg->getfield('susp') && - ( $cust_pkg->getfield('bill') || 0 ) <= $time - ) { - - # XXX should this be a package event? probably. events are called - # at collection time at the moment, though... - $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG) - if $part_pkg->can('reset_usage'); - #don't want to reset usage just cause we want a line item?? - #&& $part_pkg->pkgpart == $real_pkgpart; - - warn " bill recur\n" if $DEBUG > 1; - $lineitems++; - - # XXX shared with $recur_prog - $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; - - #over two params! lets at least switch to a hashref for the rest... - my %param = ( 'precommit_hooks' => \@precommit_hooks, ); - - $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) }; - if ( $@ ) { - $dbh->rollback if $oldAutoCommit; - return "$@ running calc_recur for $cust_pkg\n"; - } + $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill ); - - #change this bit to use Date::Manip? CAREFUL with timezones (see - # mailing list archive) - my ($sec,$min,$hour,$mday,$mon,$year) = - (localtime($sdate) )[0,1,2,3,4,5]; - - #pro-rating magic - if $recur_prog fiddles $sdate, want to use that - # only for figuring next bill date, nothing else, so, reset $sdate again - # here - $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; - $cust_pkg->last_bill($sdate); - - if ( $part_pkg->freq =~ /^\d+$/ ) { - $mon += $part_pkg->freq; - until ( $mon < 12 ) { $mon -= 12; $year++; } - } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) { - my $weeks = $1; - $mday += $weeks * 7; - } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) { - my $days = $1; - $mday += $days; - } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) { - my $hours = $1; - $hour += $hours; - } else { - $dbh->rollback if $oldAutoCommit; - return "unparsable frequency: ". $part_pkg->freq; - } - $cust_pkg->setfield('bill', - timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); - + my $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $cust_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => \@cust_bill_pkg, + 'setup' => \$total_setup, + 'recur' => \$total_recur, + 'tax_matrix' => \%taxlisthash, + 'time' => $time, + 'options' => \%options, + ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; } - warn "\$setup is undefined" unless defined($setup); - warn "\$recur is undefined" unless defined($recur); - warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill); - - ### - # If there's line items, create em cust_bill_pkg records - # If $cust_pkg has been modified, update it (if we're a real pkgpart) - ### - - if ( $lineitems ) { - - if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) { - # hmm.. and if just the options are modified in some weird price plan? - - warn " package ". $cust_pkg->pkgnum. " modified; updating\n" - if $DEBUG >1; - - my $error = $cust_pkg->replace( $old_cust_pkg, - 'options' => { $cust_pkg->options }, - ); - if ( $error ) { #just in case - $dbh->rollback if $oldAutoCommit; - return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"; - } - } - - $setup = sprintf( "%.2f", $setup ); - $recur = sprintf( "%.2f", $recur ); - if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) { - $dbh->rollback if $oldAutoCommit; - return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum; - } - if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) { - $dbh->rollback if $oldAutoCommit; - return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; - } - - if ( $setup != 0 || $recur != 0 ) { - - warn " charges (setup=$setup, recur=$recur); adding line items\n" - if $DEBUG > 1; - my $cust_bill_pkg = new FS::cust_bill_pkg { - 'pkgnum' => $cust_pkg->pkgnum, - 'setup' => $setup, - 'recur' => $recur, - 'sdate' => $sdate, - 'edate' => $cust_pkg->bill, - 'details' => \@details, - }; - $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) - unless $part_pkg->pkgpart == $real_pkgpart; - push @cust_bill_pkg, $cust_bill_pkg; - - $total_setup += $setup; - $total_recur += $recur; - - ### - # handle taxes - ### - - unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { - - my @taxes = (); - my @taxoverrides = $part_pkg->part_pkg_taxoverride; - - my $prefix = - ( $conf->exists('tax-ship_address') && length($self->ship_last) ) - ? 'ship_' - : ''; - - if ( $conf->exists('enable_taxproducts') - && (scalar(@taxoverrides) || $part_pkg->taxproductnum ) - ) - { - - my @taxclassnums = (); - my $geocode = $self->geocode('cch'); - - if ( scalar( @taxoverrides ) ) { - @taxclassnums = map { $_->taxclassnum } @taxoverrides; - }elsif ( $part_pkg->taxproductnum ) { - @taxclassnums = map { $_->taxclassnum } - $part_pkg->part_pkg_taxrate('cch', $geocode); - } - - my $extra_sql = - "AND (". - join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; - - @taxes = qsearch({ 'table' => 'tax_rate', - 'hashref' => { 'geocode' => $geocode, }, - 'extra_sql' => $extra_sql, - }) - if scalar(@taxclassnums); - - - }else{ - - my %taxhash = map { $_ => $self->get("$prefix$_") } - qw( state county country ); - - $taxhash{'taxclass'} = $part_pkg->taxclass; - - @taxes = qsearch( 'cust_main_county', \%taxhash ); - - unless ( @taxes ) { - $taxhash{'taxclass'} = ''; - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } - - #one more try at a whole-country tax rate - unless ( @taxes ) { - $taxhash{$_} = '' foreach qw( state county ); - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } - - } #if $conf->exists('enable_taxproducts') - - # maybe eliminate this entirely, along with all the 0% records - unless ( @taxes ) { - $dbh->rollback if $oldAutoCommit; - my $error; - if ( $conf->exists('enable_taxproducts') ) { - $error = - "fatal: can't find tax rate for zip/taxproduct/pkgpart ". - join('/', ( map $self->get("$prefix$_"), - qw(zip) - ), - $part_pkg->taxproduct_description, - $part_pkg->pkgpart ). "\n"; - } else { - $error = - "fatal: can't find tax rate for state/county/country/taxclass ". - join('/', ( map $self->get("$prefix$_"), - qw(state county country) - ), - $part_pkg->taxclass ). "\n"; - } - return $error; - } - - foreach my $tax ( @taxes ) { - my $taxname = ref( $tax ). ' '. $tax->taxnum; - if ( exists( $taxlisthash{ $taxname } ) ) { - push @{ $taxlisthash{ $taxname } }, $cust_bill_pkg; - }else{ - $taxlisthash{ $taxname } = [ $tax, $cust_bill_pkg ]; - } - } - - - } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' - - } #if $setup != 0 || $recur != 0 - - } #if $cust_pkg->modified - } #foreach my $part_pkg } #foreach my $cust_pkg @@ -2365,27 +2353,97 @@ sub bill { return ''; } + my $postal_pkg = $self->charge_postal_fee(); + if ( $postal_pkg && !ref( $postal_pkg ) ) { + $dbh->rollback if $oldAutoCommit; + return "can't charge postal invoice fee for customer ". + $self->custnum. ": $postal_pkg"; + } + if ( $postal_pkg && + ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) || + !$conf->exists('postal_invoice-recurring_only') + ) + ) + { + foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) { + my $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $postal_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => \@cust_bill_pkg, + 'setup' => \$total_setup, + 'recur' => \$total_recur, + 'tax_matrix' => \%taxlisthash, + 'time' => $time, + 'options' => \%options, + ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + } + warn "having a look at the taxes we found...\n" if $DEBUG > 2; + + # keys are tax names (as printed on invoices / itemdesc ) + # values are listrefs of taxlisthash keys (internal identifiers) + my %taxname = (); + + # keys are taxlisthash keys (internal identifiers) + # values are (cumulative) amounts + my %tax = (); + + # keys are taxlisthash keys (internal identifiers) + # values are listrefs of cust_bill_pkg_tax_location hashrefs + my %tax_location = (); + foreach my $tax ( keys %taxlisthash ) { my $tax_object = shift @{ $taxlisthash{$tax} }; warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2; - my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } ); - unless (ref($listref_or_error)) { + my $hashref_or_error = + $tax_object->taxline( $taxlisthash{$tax}, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time + ); + unless ( ref($hashref_or_error) ) { $dbh->rollback if $oldAutoCommit; - return $listref_or_error; + return $hashref_or_error; } unshift @{ $taxlisthash{$tax} }, $tax_object; - warn "adding ". $listref_or_error->[1]. - " as ". $listref_or_error->[0]. "\n" - if $DEBUG > 2; - $tax{ $tax_object->taxname } += $listref_or_error->[1]; - if ( $taxname{ $listref_or_error->[0] } ) { - push @{ $taxname{ $listref_or_error->[0] } }, $tax_object->taxname; - }else{ - $taxname{ $listref_or_error->[0] } = [ $tax_object->taxname ]; + my $name = $hashref_or_error->{'name'}; + my $amount = $hashref_or_error->{'amount'}; + + #warn "adding $amount as $name\n"; + $taxname{ $name } ||= []; + push @{ $taxname{ $name } }, $tax; + + $tax{ $tax } += $amount; + + $tax_location{ $tax } ||= []; + if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) { + push @{ $tax_location{ $tax } }, + { + 'taxnum' => $tax_object->taxnum, + 'taxtype' => ref($tax_object), + 'pkgnum' => $tax_object->get('pkgnum'), + 'locationnum' => $tax_object->get('locationnum'), + 'amount' => sprintf('%.2f', $amount ), + }; + } + + } + + #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit + my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg; + foreach my $tax ( keys %taxlisthash ) { + foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) { + next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen + + push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, + splice( @{ $_->_cust_tax_exempt_pkg } ); } - } #some taxes are taxed @@ -2407,9 +2465,9 @@ sub bill { # existing taxes warn "adding $totname to taxed taxes\n" if $DEBUG > 2; if ( exists( $totlisthash{ $totname } ) ) { - push @{ $totlisthash{ $totname } }, $tax{ $tax_object->taxname }; + push @{ $totlisthash{ $totname } }, $tax{ $tax }; }else{ - $totlisthash{ $totname } = [ $tot, $tax{ $tax_object->taxname } ]; + $totlisthash{ $totname } = [ $tot, $tax{ $tax } ]; } } } @@ -2419,7 +2477,11 @@ sub bill { my $tax_object = shift @{ $totlisthash{$tax} }; warn "found previously found taxed tax ". $tax_object->taxname. "\n" if $DEBUG > 2; - my $listref_or_error = $tax_object->taxline( @{ $totlisthash{$tax} } ); + my $listref_or_error = + $tax_object->taxline( $totlisthash{$tax}, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time + ); unless (ref($listref_or_error)) { $dbh->rollback if $oldAutoCommit; return $listref_or_error; @@ -2428,7 +2490,7 @@ sub bill { warn "adding taxed tax amount ". $listref_or_error->[1]. " as ". $tax_object->taxname. "\n" if $DEBUG; - $tax{ $tax_object->taxname } += $listref_or_error->[1]; + $tax{ $tax } += $listref_or_error->[1]; } #consolidate and create tax line items @@ -2436,10 +2498,15 @@ sub bill { foreach my $taxname ( keys %taxname ) { my $tax = 0; my %seen = (); + my @cust_bill_pkg_tax_location = (); warn "adding $taxname\n" if $DEBUG > 1; foreach my $taxitem ( @{ $taxname{$taxname} } ) { - $tax += $tax{$taxitem} unless $seen{$taxitem}; + next if $seen{$taxitem}++; warn "adding $tax{$taxitem}\n" if $DEBUG > 1; + $tax += $tax{$taxitem}; + push @cust_bill_pkg_tax_location, + map { new FS::cust_bill_pkg_tax_location $_ } + @{ $tax_location{ $taxitem } }; } next unless $tax; @@ -2453,6 +2520,7 @@ sub bill { 'sdate' => '', 'edate' => '', 'itemdesc' => $taxname, + 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location, }; } @@ -2462,7 +2530,7 @@ sub bill { #create the new invoice my $cust_bill = new FS::cust_bill ( { 'custnum' => $self->custnum, - '_date' => ( $options{'invoice_time'} || $time ), + '_date' => ( $invoice_time ), 'charged' => $charged, } ); my $error = $cust_bill->insert; @@ -2495,26 +2563,420 @@ sub bill { ''; #no error } -=item collect OPTIONS -(Attempt to) collect money for this customer's outstanding invoices (see -L). Usually used after the bill method. +sub _make_lines { + my ($self, %params) = @_; -Actions are now triggered by billing events; see L and the -billing events web interface. Old-style invoice events (see -L) have been deprecated. + my $part_pkg = $params{part_pkg} or die "no part_pkg specified"; + my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified"; + my $precommit_hooks = $params{precommit_hooks} or die "no package specified"; + my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified"; + my $total_setup = $params{setup} or die "no setup accumulator specified"; + my $total_recur = $params{recur} or die "no recur accumulator specified"; + my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified"; + my $time = $params{'time'} or die "no time specified"; + my (%options) = %{$params{options}}; #hmmm only for 'resetup' -If there is an error, returns the error, otherwise returns false. + my $dbh = dbh; + my $real_pkgpart = $cust_pkg->pkgpart; + my %hash = $cust_pkg->hash; + my $old_cust_pkg = new FS::cust_pkg \%hash; -Options are passed as name-value pairs. + my @details = (); -Currently available options are: + my $lineitems = 0; -=over 4 + $cust_pkg->pkgpart($part_pkg->pkgpart); -=item invoice_time + ### + # bill setup + ### -Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L). Also see L and L for conversion functions. + my $setup = 0; + my $unitsetup = 0; + if ( ! $cust_pkg->setup && + ( + ( $conf->exists('disable_setup_suspended_pkgs') && + ! $cust_pkg->getfield('susp') + ) || ! $conf->exists('disable_setup_suspended_pkgs') + ) + || $options{'resetup'} + ) { + + warn " bill setup\n" if $DEBUG > 1; + $lineitems++; + + $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; + return "$@ running calc_setup for $cust_pkg\n" + if $@; + + $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + + $cust_pkg->setfield('setup', $time) + unless $cust_pkg->setup; + #do need it, but it won't get written to the db + #|| $cust_pkg->pkgpart != $real_pkgpart; + + } + + ### + # bill recurring fee + ### + + #XXX unit stuff here too + my $recur = 0; + my $unitrecur = 0; + my $sdate; + if ( ! $cust_pkg->getfield('susp') and + ( $part_pkg->getfield('freq') ne '0' && + ( $cust_pkg->getfield('bill') || 0 ) <= $time + ) + || ( $part_pkg->plan eq 'voip_cdr' + && $part_pkg->option('bill_every_call') + ) + ) { + + # XXX should this be a package event? probably. events are called + # at collection time at the moment, though... + $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG) + if $part_pkg->can('reset_usage'); + #don't want to reset usage just cause we want a line item?? + #&& $part_pkg->pkgpart == $real_pkgpart; + + warn " bill recur\n" if $DEBUG > 1; + $lineitems++; + + # XXX shared with $recur_prog + $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + + #over two params! lets at least switch to a hashref for the rest... + my $increment_next_bill = ( $part_pkg->freq ne '0' + && ( $cust_pkg->getfield('bill') || 0 ) <= $time + ); + my %param = ( 'precommit_hooks' => $precommit_hooks, + 'increment_next_bill' => $increment_next_bill, + ); + + $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) }; + return "$@ running calc_recur for $cust_pkg\n" + if ( $@ ); + + if ( $increment_next_bill ) { + + my $next_bill = $part_pkg->add_freq($sdate); + return "unparsable frequency: ". $part_pkg->freq + if $next_bill == -1; + + #pro-rating magic - if $recur_prog fiddled $sdate, want to use that + # only for figuring next bill date, nothing else, so, reset $sdate again + # here + $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill; + $cust_pkg->last_bill($sdate); + + $cust_pkg->setfield('bill', $next_bill ); + + } + + } + + warn "\$setup is undefined" unless defined($setup); + warn "\$recur is undefined" unless defined($recur); + warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill); + + ### + # If there's line items, create em cust_bill_pkg records + # If $cust_pkg has been modified, update it (if we're a real pkgpart) + ### + + if ( $lineitems ) { + + if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) { + # hmm.. and if just the options are modified in some weird price plan? + + warn " package ". $cust_pkg->pkgnum. " modified; updating\n" + if $DEBUG >1; + + my $error = $cust_pkg->replace( $old_cust_pkg, + 'options' => { $cust_pkg->options }, + ); + return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error" + if $error; #just in case + } + + $setup = sprintf( "%.2f", $setup ); + $recur = sprintf( "%.2f", $recur ); + if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) { + return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum; + } + if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) { + return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; + } + + if ( $setup != 0 || $recur != 0 ) { + + warn " charges (setup=$setup, recur=$recur); adding line items\n" + if $DEBUG > 1; + + my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I'); + if ( $DEBUG > 1 ) { + warn " adding customer package invoice detail: $_\n" + foreach @cust_pkg_detail; + } + push @details, @cust_pkg_detail; + + my $cust_bill_pkg = new FS::cust_bill_pkg { + 'pkgnum' => $cust_pkg->pkgnum, + 'setup' => $setup, + 'unitsetup' => $unitsetup, + 'recur' => $recur, + 'unitrecur' => $unitrecur, + 'quantity' => $cust_pkg->quantity, + 'details' => \@details, + }; + + if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) { + $cust_bill_pkg->sdate( $hash{last_bill} ); + $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1 + } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) { + $cust_bill_pkg->sdate( $sdate ); + $cust_bill_pkg->edate( $cust_pkg->bill ); + } + + $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) + unless $part_pkg->pkgpart == $real_pkgpart; + + $$total_setup += $setup; + $$total_recur += $recur; + + ### + # handle taxes + ### + + my $error = + $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg); + return $error if $error; + + push @$cust_bill_pkgs, $cust_bill_pkg; + + } #if $setup != 0 || $recur != 0 + + } #if $line_items + + ''; + +} + +sub _handle_taxes { + my $self = shift; + my $part_pkg = shift; + my $taxlisthash = shift; + my $cust_bill_pkg = shift; + my $cust_pkg = shift; + + my %cust_bill_pkg = (); + my %taxes = (); + + my @classes; + #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; + push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + push @classes, 'setup' if $cust_bill_pkg->setup; + push @classes, 'recur' if $cust_bill_pkg->recur; + + if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) { + + if ( $conf->exists('enable_taxproducts') + && ( scalar($part_pkg->part_pkg_taxoverride) + || $part_pkg->has_taxproduct + ) + ) + { + + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + return "fatal: Can't (yet) use tax-pkg_address with taxproducts"; + } + + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes( $part_pkg, $class ); + return $err_or_ref unless ref($err_or_ref); + $taxes{$class} = $err_or_ref; + } + + unless (exists $taxes{''}) { + my $err_or_ref = $self->_gather_taxes( $part_pkg, '' ); + return $err_or_ref unless ref($err_or_ref); + $taxes{''} = $err_or_ref; + } + + } else { + + my @loc_keys = qw( state county country ); + my %taxhash; + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + my $cust_location = $cust_pkg->cust_location; + %taxhash = map { $_ => $cust_location->$_() } @loc_keys; + } else { + my $prefix = + ( $conf->exists('tax-ship_address') && length($self->ship_last) ) + ? 'ship_' + : ''; + %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys; + } + + $taxhash{'taxclass'} = $part_pkg->taxclass; + + my @taxes = qsearch( 'cust_main_county', \%taxhash ); + + my %taxhash_elim = %taxhash; + + # no, unexpected change in behavior. + #my @elim = qw( taxclass county state ); + #while ( !scalar(@taxes) && scalar(@elim) ) { + # $taxhash_elim{ shift(@elim) } = ''; + # @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + #} + + #just try taxclass first, then state+county, not county in the middle + unless ( @taxes ) { + $taxhash_elim{'taxclass'} = ''; + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + } + #one more try at a whole-country tax rate + unless ( @taxes ) { + $taxhash_elim{$_} = '' foreach qw( state county ); + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + } + + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + foreach (@taxes) { + $_->set('pkgnum', $cust_pkg->pkgnum ); + $_->set('locationnum', $cust_pkg->locationnum ); + } + } + + $taxes{''} = [ @taxes ]; + $taxes{'setup'} = [ @taxes ]; + $taxes{'recur'} = [ @taxes ]; + $taxes{$_} = [ @taxes ] foreach (@classes); + + # maybe eliminate this entirely, along with all the 0% records + unless ( @taxes ) { + return + "fatal: can't find tax rate for state/county/country/taxclass ". + join('/', map $taxhash{$_}, qw(state county country taxclass) ); + } + + } #if $conf->exists('enable_taxproducts') ... + + } + + my @display = (); + if ( $conf->exists('separate_usage') ) { + my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!'); + my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!'); + push @display, new FS::cust_bill_pkg_display { type => 'S' }; + push @display, new FS::cust_bill_pkg_display { type => 'R' }; + push @display, new FS::cust_bill_pkg_display { type => 'U', + section => $section + }; + if ($section && $summary) { + $display[2]->post_total('Y'); + push @display, new FS::cust_bill_pkg_display { type => 'U', + summary => 'Y', + } + } + } + $cust_bill_pkg->set('display', \@display); + + my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; + foreach my $key (keys %tax_cust_bill_pkg) { + my @taxes = @{ $taxes{$key} || [] }; + my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + + foreach my $tax ( @taxes ) { + + my $taxname = ref( $tax ). ' taxnum'. $tax->taxnum; +# $taxname .= ' pkgnum'. $cust_pkg->pkgnum. +# ' locationnum'. $cust_pkg->locationnum +# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum; + + if ( exists( $taxlisthash->{ $taxname } ) ) { + push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; + }else{ + $taxlisthash->{ $taxname } = [ $tax, $tax_cust_bill_pkg ]; + } + } + } + + ''; +} + +sub _gather_taxes { + my $self = shift; + my $part_pkg = shift; + my $class = shift; + + my @taxes = (); + my $geocode = $self->geocode('cch'); + + my @taxclassnums = map { $_->taxclassnum } + $part_pkg->part_pkg_taxoverride($class); + + unless (@taxclassnums) { + @taxclassnums = map { $_->taxclassnum } + $part_pkg->part_pkg_taxrate('cch', $geocode, $class); + } + warn "Found taxclassnum values of ". join(',', @taxclassnums) + if $DEBUG; + + my $extra_sql = + "AND (". + join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; + + @taxes = qsearch({ 'table' => 'tax_rate', + 'hashref' => { 'geocode' => $geocode, }, + 'extra_sql' => $extra_sql, + }) + if scalar(@taxclassnums); + + # maybe eliminate this entirely, along with all the 0% records + unless ( @taxes ) { + return + "fatal: can't find tax rate for geocode/taxproduct/pkgpart ". + join('/', $geocode, + $part_pkg->taxproduct_description, + $part_pkg->pkgpart + ); + } + + warn "Found taxes ". + join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" + if $DEBUG; + + [ @taxes ]; + +} + +=item collect OPTIONS + +(Attempt to) collect money for this customer's outstanding invoices (see +L). Usually used after the bill method. + +Actions are now triggered by billing events; see L and the +billing events web interface. Old-style invoice events (see +L) have been deprecated. + +If there is an error, returns the error, otherwise returns false. + +Options are passed as name-value pairs. + +Currently available options are: + +=over 4 + +=item invoice_time + +Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L). Also see L and L for conversion functions. =item retry @@ -2662,6 +3124,11 @@ Only return events for the specified eventtable (by default, events of all event Explicitly pass the objects to be tested (typically used with eventtable). +=item testonly + +Set to true to return the objects, but not actually insert them into the +database. + =back =cut @@ -2692,7 +3159,8 @@ sub due_cust_event { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - $self->select_for_update; #mutex + $self->select_for_update #mutex + unless $opt{testonly}; ### # 1: find possible events (initial search) @@ -2807,14 +3275,16 @@ sub due_cust_event { # 3: insert ## - foreach my $cust_event ( @cust_event ) { + unless( $opt{testonly} ) { + foreach my $cust_event ( @cust_event ) { - my $error = $cust_event->insert(); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } + my $error = $cust_event->insert(); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } } $dbh->commit or die $dbh->errstr if $oldAutoCommit; @@ -3466,7 +3936,7 @@ sub realtime_bop { my $templ_hash = { error => $transaction->error_message }; my $error = send_email( - 'from' => $conf->config('invoice_from'), + 'from' => $conf->config('invoice_from', $self->agentnum ), 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], 'subject' => 'Your payment could not be processed', 'body' => [ $template->fill_in(HASH => $templ_hash) ], @@ -4025,7 +4495,9 @@ sub batch_card { die $error; } - my $unapplied = $self->total_credited + $self->total_unapplied_payments + $self->in_transit_payments; + my $unapplied = $self->total_unapplied_credits + + $self->total_unapplied_payments + + $self->in_transit_payments; foreach my $cust_bill ($self->open_cust_bill) { #$dbh->commit or die $dbh->errstr if $oldAutoCommit; my $cust_bill_pay_batch = new FS::cust_bill_pay_batch { @@ -4052,39 +4524,6 @@ sub batch_card { ''; } -=item total_owed - -Returns the total owed for this customer on all invoices -(see L). - -=cut - -sub total_owed { - my $self = shift; - $self->total_owed_date(2145859200); #12/31/2037 -} - -=item total_owed_date TIME - -Returns the total owed for this customer on all invoices with date earlier than -TIME. TIME is specified as a UNIX timestamp; see L). Also -see L and L for conversion functions. - -=cut - -sub total_owed_date { - my $self = shift; - my $time = shift; - my $total_bill = 0; - foreach my $cust_bill ( - grep { $_->_date <= $time } - qsearch('cust_bill', { 'custnum' => $self->custnum, } ) - ) { - $total_bill += $cust_bill->owed; - } - sprintf( "%.2f", $total_bill ); -} - =item apply_payments_and_credits Applies unapplied payments and credits. @@ -4154,7 +4593,7 @@ sub apply_credits { $self->select_for_update; #mutex - unless ( $self->total_credited ) { + unless ( $self->total_unapplied_credits ) { $dbh->commit or die $dbh->errstr if $oldAutoCommit; return 0; } @@ -4195,11 +4634,11 @@ sub apply_credits { } - my $total_credited = $self->total_credited; + my $total_unapplied_credits = $self->total_unapplied_credits; $dbh->commit or die $dbh->errstr if $oldAutoCommit; - return $total_credited; + return $total_unapplied_credits; } =item apply_payments @@ -4231,11 +4670,13 @@ sub apply_payments { #return 0 unless - my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 } - qsearch('cust_pay', { 'custnum' => $self->custnum } ) ); + my @payments = sort { $b->_date <=> $a->_date } + grep { $_->unapplied > 0 } + $self->cust_pay; - my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 } - qsearch('cust_bill', { 'custnum' => $self->custnum } ) ); + my @invoices = sort { $a->_date <=> $b->_date} + grep { $_->owed > 0 } + $self->cust_bill; my $payment; @@ -4274,21 +4715,72 @@ sub apply_payments { return $total_unapplied_payments; } -=item total_credited +=item total_owed + +Returns the total owed for this customer on all invoices +(see L). + +=cut + +sub total_owed { + my $self = shift; + $self->total_owed_date(2145859200); #12/31/2037 +} + +=item total_owed_date TIME + +Returns the total owed for this customer on all invoices with date earlier than +TIME. TIME is specified as a UNIX timestamp; see L). Also +see L and L for conversion functions. + +=cut + +sub total_owed_date { + my $self = shift; + my $time = shift; + my $total_bill = 0; + foreach my $cust_bill ( + grep { $_->_date <= $time } + qsearch('cust_bill', { 'custnum' => $self->custnum, } ) + ) { + $total_bill += $cust_bill->owed; + } + sprintf( "%.2f", $total_bill ); +} + +=item total_paid + +Returns the total amount of all payments. + +=cut + +sub total_paid { + my $self = shift; + my $total = 0; + $total += $_->paid foreach $self->cust_pay; + sprintf( "%.2f", $total ); +} + +=item total_unapplied_credits Returns the total outstanding credit (see L) for this customer. See L. +=item total_credited + +Old name for total_unapplied_credits. Don't use. + =cut sub total_credited { + #carp "total_credited deprecated, use total_unapplied_credits"; + shift->total_unapplied_credits(@_); +} + +sub total_unapplied_credits { my $self = shift; my $total_credit = 0; - foreach my $cust_credit ( qsearch('cust_credit', { - 'custnum' => $self->custnum, - } ) ) { - $total_credit += $cust_credit->credited; - } + $total_credit += $_->credited foreach $self->cust_credit; sprintf( "%.2f", $total_credit ); } @@ -4302,11 +4794,7 @@ See L. sub total_unapplied_payments { my $self = shift; my $total_unapplied = 0; - foreach my $cust_pay ( qsearch('cust_pay', { - 'custnum' => $self->custnum, - } ) ) { - $total_unapplied += $cust_pay->unapplied; - } + $total_unapplied += $_->unapplied foreach $self->cust_pay; sprintf( "%.2f", $total_unapplied ); } @@ -4320,18 +4808,14 @@ customer. See L. sub total_unapplied_refunds { my $self = shift; my $total_unapplied = 0; - foreach my $cust_refund ( qsearch('cust_refund', { - 'custnum' => $self->custnum, - } ) ) { - $total_unapplied += $cust_refund->unapplied; - } + $total_unapplied += $_->unapplied foreach $self->cust_refund; sprintf( "%.2f", $total_unapplied ); } =item balance Returns the balance for this customer (total_owed plus total_unrefunded, minus -total_credited minus total_unapplied_payments). +total_unapplied_credits minus total_unapplied_payments). =cut @@ -4340,7 +4824,7 @@ sub balance { sprintf( "%.2f", $self->total_owed + $self->total_unapplied_refunds - - $self->total_credited + - $self->total_unapplied_credits - $self->total_unapplied_payments ); } @@ -4361,7 +4845,7 @@ sub balance_date { sprintf( "%.2f", $self->total_owed_date($time) + $self->total_unapplied_refunds - - $self->total_credited + - $self->total_unapplied_credits - $self->total_unapplied_payments ); } @@ -4649,43 +5133,74 @@ sub referring_cust_main { qsearchs('cust_main', { 'custnum' => $self->referral_custnum } ); } -=item credit AMOUNT, REASON +=item credit AMOUNT, REASON [ , OPTION => VALUE ... ] Applies a credit to this customer. If there is an error, returns the error, otherwise returns false. +REASON can be a text string, an FS::reason object, or a scalar reference to +a reasonnum. If a text string, it will be automatically inserted as a new +reason, and a 'reason_type' option must be passed to indicate the +FS::reason_type for the new reason. + +An I option may be passed to set the credit's I field. + +Any other options are passed to FS::cust_credit::insert. + =cut sub credit { my( $self, $amount, $reason, %options ) = @_; + my $cust_credit = new FS::cust_credit { 'custnum' => $self->custnum, 'amount' => $amount, - 'reason' => $reason, }; - $cust_credit->insert(%options); -} -=item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ] + if ( ref($reason) ) { -Creates a one-time charge for this customer. If there is an error, returns -the error, otherwise returns false. + if ( ref($reason) eq 'SCALAR' ) { + $cust_credit->reasonnum( $$reason ); + } else { + $cust_credit->reasonnum( $reason->reasonnum ); + } -=cut + } else { + $cust_credit->set('reason', $reason) + } + + $cust_credit->addlinfo( delete $options{'addlinfo'} ) + if exists($options{'addlinfo'}); + + $cust_credit->insert(%options); + +} + +=item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ] + +Creates a one-time charge for this customer. If there is an error, returns +the error, otherwise returns false. + +=cut sub charge { my $self = shift; - my ( $amount, $pkg, $comment, $taxclass, $additional, $classnum ); + my ( $amount, $quantity, $pkg, $comment, $taxclass, $additional, $classnum ); + my ( $taxproduct, $override ); if ( ref( $_[0] ) ) { $amount = $_[0]->{amount}; + $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge'; $comment = exists($_[0]->{comment}) ? $_[0]->{comment} : '$'. sprintf("%.2f",$amount); $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : ''; $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : ''; $additional = $_[0]->{additional}; + $taxproduct = $_[0]->{taxproductnum}; + $override = { '' => $_[0]->{tax_override} }; }else{ $amount = shift; + $quantity = 1; $pkg = @_ ? shift : 'One-time charge'; $comment = @_ ? shift : '$'. sprintf("%.2f",$amount); $taxclass = @_ ? shift : ''; @@ -4704,13 +5219,14 @@ sub charge { my $dbh = dbh; my $part_pkg = new FS::part_pkg ( { - 'pkg' => $pkg, - 'comment' => $comment, - 'plan' => 'flat', - 'freq' => 0, - 'disabled' => 'Y', - 'classnum' => $classnum ? $classnum : '', - 'taxclass' => $taxclass, + 'pkg' => $pkg, + 'comment' => $comment, + 'plan' => 'flat', + 'freq' => 0, + 'disabled' => 'Y', + 'classnum' => $classnum ? $classnum : '', + 'taxclass' => $taxclass, + 'taxproductnum' => $taxproduct, } ); my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) } @@ -4720,7 +5236,9 @@ sub charge { 'setup_fee' => $amount, ); - my $error = $part_pkg->insert( options => \%options ); + my $error = $part_pkg->insert( options => \%options, + tax_overrides => $override, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -4738,8 +5256,9 @@ sub charge { } my $cust_pkg = new FS::cust_pkg ( { - 'custnum' => $self->custnum, - 'pkgpart' => $pkgpart, + 'custnum' => $self->custnum, + 'pkgpart' => $pkgpart, + 'quantity' => $quantity, } ); $error = $cust_pkg->insert; @@ -4753,6 +5272,33 @@ sub charge { } +#=item charge_postal_fee +# +#Applies a one time charge this customer. If there is an error, +#returns the error, returns the cust_pkg charge object or false +#if there was no charge. +# +#=cut +# +# This should be a customer event. For that to work requires that bill +# also be a customer event. + +sub charge_postal_fee { + my $self = shift; + + my $pkgpart = $conf->config('postal_invoice-fee_pkgpart'); + return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list); + + my $cust_pkg = new FS::cust_pkg ( { + 'custnum' => $self->custnum, + 'pkgpart' => $pkgpart, + 'quantity' => 1, + } ); + + my $error = $cust_pkg->insert; + $error ? $error : $cust_pkg; +} + =item cust_bill Returns all the invoices (see L) for this customer. @@ -4837,6 +5383,22 @@ sub cust_refund { qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) } +=item display_custnum + +Returns the displayed customer number for this customer: agent_custid if +cust_main-default_agent_custid is set and it has a value, custnum otherwise. + +=cut + +sub display_custnum { + my $self = shift; + if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){ + return $self->agent_custid; + } else { + return $self->custnum; + } +} + =item name Returns a name string for this customer, either "Company (Last, First)" or @@ -4869,6 +5431,35 @@ sub ship_name { } } +=item name_short + +Returns a name string for this customer, either "Company" or "First Last". + +=cut + +sub name_short { + my $self = shift; + $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast; +} + +=item ship_name_short + +Returns a name string for this (service/shipping) contact, either "Company" +or "First Last". + +=cut + +sub ship_name_short { + my $self = shift; + if ( $self->get('ship_last') ) { + $self->ship_company !~ /^\s*$/ + ? $self->ship_company + : $self->ship_contact_firstlast; + } else { + $self->name_company_or_firstlast; + } +} + =item contact Returns this customer's full (billing) contact name only, "Last, First" @@ -4893,6 +5484,30 @@ sub ship_contact { : $self->contact; } +=item contact_firstlast + +Returns this customers full (billing) contact name only, "First Last". + +=cut + +sub contact_firstlast { + my $self = shift; + $self->first. ' '. $self->get('last'); +} + +=item ship_contact_firstlast + +Returns this customer's full (shipping) contact name only, "First Last". + +=cut + +sub ship_contact_firstlast { + my $self = shift; + $self->get('ship_last') + ? $self->first. ' '. $self->get('ship_last') + : $self->contact_firstlast; +} + =item country_full Returns this customer's full country name @@ -4914,6 +5529,9 @@ Currently this only makes sense for "CCH" as DATA_VENDOR. sub geocode { my ($self, $data_vendor) = (shift, shift); #always cch for now + my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode + return $geocode if $geocode; + my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) ) ? 'ship_' : ''; @@ -4924,16 +5542,16 @@ sub geocode { #CCH specific location stuff my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'"; - my $geocode = ''; - my $cust_tax_location = - qsearchs( { - 'table' => 'cust_tax_location', - 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor }, - 'extra_sql' => $extra_sql, - } - ); - $geocode = $cust_tax_location->geocode - if $cust_tax_location; + my @cust_tax_location = + qsearch( { + 'table' => 'cust_tax_location', + 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor }, + 'extra_sql' => $extra_sql, + 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends + } + ); + $geocode = $cust_tax_location[0]->geocode + if scalar(@cust_tax_location); $geocode; } @@ -4996,7 +5614,7 @@ Returns a hex triplet color string for this customer's status. =cut use vars qw(%statuscolor); -tie my %statuscolor, 'Tie::IxHash', +tie %statuscolor, 'Tie::IxHash', 'prospect' => '7e0079', #'000000', #black? naw, purple 'active' => '00CC00', #green 'inactive' => '0000CC', #blue @@ -5023,22 +5641,24 @@ sub tickets { my $num = $conf->config('cust_main-max_tickets') || 10; my @tickets = (); - unless ( $conf->config('ticket_system-custom_priority_field') ) { + if ( $conf->config('ticket_system') ) { + unless ( $conf->config('ticket_system-custom_priority_field') ) { - @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) }; + @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) }; - } else { + } else { - foreach my $priority ( - $conf->config('ticket_system-custom_priority_field-values'), '' - ) { - last if scalar(@tickets) >= $num; - push @tickets, - @{ FS::TicketSystem->customer_tickets( $self->custnum, - $num - scalar(@tickets), - $priority, - ) - }; + foreach my $priority ( + $conf->config('ticket_system-custom_priority_field-values'), '' + ) { + last if scalar(@tickets) >= $num; + push @tickets, + @{ FS::TicketSystem->customer_tickets( $self->custnum, + $num - scalar(@tickets), + $priority, + ) + }; + } } } (@tickets); @@ -5100,7 +5720,7 @@ sub prospect_sql { " =item active_sql Returns an SQL expression identifying active cust_main records (customers with -no active recurring packages, but otherwise unsuspended/uncancelled). +active recurring packages). =cut @@ -5112,7 +5732,7 @@ sub active_sql { " =item inactive_sql Returns an SQL expression identifying inactive cust_main records (customers with -active recurring packages). +no active recurring packages, but otherwise unsuspended/uncancelled). =cut @@ -5148,17 +5768,17 @@ sub cancelled_sql { cancel_sql(@_); } sub cancel_sql { my $recurring_sql = FS::cust_pkg->recurring_sql; - #my $recurring_sql = " - # '0' != ( select freq from part_pkg - # where cust_pkg.pkgpart = part_pkg.pkgpart ) - #"; + my $cancelled_sql = FS::cust_pkg->cancelled_sql; " - 0 < ( $select_count_pkgs ) + 0 < ( $select_count_pkgs ) + AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql ) AND 0 = ( $select_count_pkgs AND $recurring_sql AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 ) ) + AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) "; + } =item uncancel_sql @@ -5200,7 +5820,7 @@ sub balance_sql { " Returns an SQL fragment to retreive the balance for this customer, only considering invoices with date earlier than START_TIME, and optionally not -later than END_TIME (total_owed_date minus total_credited minus +later than END_TIME (total_owed_date minus total_unapplied_credits minus total_unapplied_payments). Times are specified as SQL fragments or numeric @@ -5212,15 +5832,24 @@ Available options are: =over 4 -=item unapplied_date - set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering) +=item unapplied_date + +set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering) + +=item total + +(unused. obsolete?) +set to true to remove all customer comparison clauses, for totals -=item total - set to true to remove all customer comparison clauses, for totals +=item where -=item where - WHERE clause hashref (elements "AND"ed together) (typically used with the total option) +(unused. obsolete?) +WHERE clause hashref (elements "AND"ed together) (typically used with the total option) -=item join - JOIN clause (typically used with the total option) +=item join -=item +(unused. obsolete?) +JOIN clause (typically used with the total option) =back @@ -5279,6 +5908,312 @@ sub _money_table_where { } +=item search_sql HASHREF + +(Class method) + +Returns a qsearch hash expression to search for parameters specified in HREF. +Valid parameters are + +=over 4 + +=item agentnum + +=item status + +=item cancelled_pkgs + +bool + +=item signupdate + +listref of start date, end date + +=item payby + +listref + +=item current_balance + +listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance')) + +=item cust_fields + +=item flattened_pkgs + +bool + +=back + +=cut + +sub search_sql { + my ($class, $params) = @_; + + my $dbh = dbh; + + my @where = (); + my $orderby; + + ## + # parse agent + ## + + if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) { + push @where, + "cust_main.agentnum = $1"; + } + + ## + # parse status + ## + + #prospect active inactive suspended cancelled + if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) { + my $method = $params->{'status'}. '_sql'; + #push @where, $class->$method(); + push @where, FS::cust_main->$method(); + } + + ## + # parse cancelled package checkbox + ## + + my $pkgwhere = ""; + + $pkgwhere .= "AND (cancel = 0 or cancel is null)" + unless $params->{'cancelled_pkgs'}; + + ## + # dates + ## + + foreach my $field (qw( signupdate )) { + + next unless exists($params->{$field}); + + my($beginning, $ending) = @{$params->{$field}}; + + push @where, + "cust_main.$field IS NOT NULL", + "cust_main.$field >= $beginning", + "cust_main.$field <= $ending"; + + $orderby ||= "ORDER BY cust_main.$field"; + + } + + ### + # payby + ### + + my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} }; + if ( @payby ) { + push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'; + } + + ## + # amounts + ## + + #my $balance_sql = $class->balance_sql(); + my $balance_sql = FS::cust_main->balance_sql(); + + push @where, map { s/current_balance/$balance_sql/; $_ } + @{ $params->{'current_balance'} }; + + ## + # custbatch + ## + + if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) { + push @where, + "cust_main.custbatch = '$1'"; + } + + ## + # setup queries, subs, etc. for the search + ## + + $orderby ||= 'ORDER BY custnum'; + + # here is the agent virtualization + push @where, $FS::CurrentUser::CurrentUser->agentnums_sql; + + my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : ''; + + my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) '; + + my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql"; + + my $select = join(', ', + 'cust_main.custnum', + FS::UI::Web::cust_sql_fields($params->{'cust_fields'}), + ); + + my(@extra_headers) = (); + my(@extra_fields) = (); + + if ($params->{'flattened_pkgs'}) { + + if ($dbh->{Driver}->{Name} eq 'Pg') { + + $select .= ", array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic"; + + }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) { + $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic"; + $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )"; + }else{ + warn "warning: unknown database type ". $dbh->{Driver}->{Name}. + "omitting packing information from report."; + } + + my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1"; + + my $sth = dbh->prepare($header_query) or die dbh->errstr; + $sth->execute() or die $sth->errstr; + my $headerrow = $sth->fetchrow_arrayref; + my $headercount = $headerrow ? $headerrow->[0] : 0; + while($headercount) { + unshift @extra_headers, "Package ". $headercount; + unshift @extra_fields, eval q!sub {my $c = shift; + my @a = split '\|', $c->magic; + my $p = $a[!.--$headercount. q!]; + $p; + };!; + } + + } + + my $sql_query = { + 'table' => 'cust_main', + 'select' => $select, + 'hashref' => {}, + 'extra_sql' => $extra_sql, + 'order_by' => $orderby, + 'count_query' => $count_query, + 'extra_headers' => \@extra_headers, + 'extra_fields' => \@extra_fields, + }; + +} + +=item email_search_sql HASHREF + +(Class method) + +Emails a notice to the specified customers. + +Valid parameters are those of the L method, plus the following: + +=over 4 + +=item from + +From: address + +=item subject + +Email Subject: + +=item html_body + +HTML body + +=item text_body + +Text body + +=item job + +Optional job queue job for status updates. + +=back + +Returns an error message, or false for success. + +If an error occurs during any email, stops the enture send and returns that +error. Presumably if you're getting SMTP errors aborting is better than +retrying everything. + +=cut + +sub email_search_sql { + my($class, $params) = @_; + + my $from = delete $params->{from}; + my $subject = delete $params->{subject}; + my $html_body = delete $params->{html_body}; + my $text_body = delete $params->{text_body}; + + my $job = delete $params->{'job'}; + + my $sql_query = $class->search_sql($params); + + my $count_query = delete($sql_query->{'count_query'}); + my $count_sth = dbh->prepare($count_query) + or die "Error preparing $count_query: ". dbh->errstr; + $count_sth->execute + or die "Error executing $count_query: ". $count_sth->errstr; + my $count_arrayref = $count_sth->fetchrow_arrayref; + my $num_cust = $count_arrayref->[0]; + + #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) }; + #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) }; + + + my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo + + #eventually order+limit magic to reduce memory use? + foreach my $cust_main ( qsearch($sql_query) ) { + + my $to = $cust_main->invoicing_list_emailonly_scalar; + next unless $to; + + my $error = send_email( + generate_email( + 'from' => $from, + 'to' => $to, + 'subject' => $subject, + 'html_body' => $html_body, + 'text_body' => $text_body, + ) + ); + return $error if $error; + + if ( $job ) { #progressbar foo + $num++; + if ( time - $min_sec > $last ) { + my $error = $job->update_statustext( + int( 100 * $num / $num_cust ) + ); + die $error if $error; + $last = time; + } + } + + } + + return ''; +} + +use Storable qw(thaw); +use Data::Dumper; +use MIME::Base64; +sub process_email_search_sql { + my $job = shift; + #warn "$me process_re_X $method for job $job\n" if $DEBUG; + + my $param = thaw(decode_base64(shift)); + warn Dumper($param) if $DEBUG; + + $param->{'job'} = $job; + + my $error = FS::cust_main->email_search_sql( $param ); + die $error if $error; + +} + =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ] Performs a fuzzy (approximate) search and returns the matching FS::cust_main @@ -5409,22 +6344,28 @@ sub smart_search { # custnum search (also try agent_custid), with some tweaking options if your # legacy cust "numbers" have letters - } elsif ( $search =~ /^\s*(\d+)\s*$/ + } + + if ( $search =~ /^\s*(\d+)\s*$/ || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+' && $search =~ /^\s*(\w\w?\d+)\s*$/ ) ) { - push @cust_main, qsearch( { - 'table' => 'cust_main', - 'hashref' => { 'custnum' => $1, %options }, - 'extra_sql' => " AND $agentnums_sql", #agent virtualization - } ); + my $num = $1; + + if ( $num <= 2147483647 ) { #need a bigint custnum? wow. + push @cust_main, qsearch( { + 'table' => 'cust_main', + 'hashref' => { 'custnum' => $num, %options }, + 'extra_sql' => " AND $agentnums_sql", #agent virtualization + } ); + } push @cust_main, qsearch( { 'table' => 'cust_main', - 'hashref' => { 'agent_custid' => $1, %options }, + 'hashref' => { 'agent_custid' => $num, %options }, 'extra_sql' => " AND $agentnums_sql", #agent virtualization } ); @@ -5520,7 +6461,7 @@ sub smart_search { #getting complaints searches are not returning enough unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) { - #still some false laziness w/ search/cust_main.cgi + #still some false laziness w/search_sql (was search/cust_main.cgi) #substring @@ -5758,214 +6699,6 @@ sub append_fuzzyfiles { 1; } -=item batch_import - -=cut - -sub batch_import { - my $param = shift; - #warn join('-',keys %$param); - my $fh = $param->{filehandle}; - my $agentnum = $param->{agentnum}; - - my $refnum = $param->{refnum}; - my $pkgpart = $param->{pkgpart}; - - #my @fields = @{$param->{fields}}; - my $format = $param->{'format'}; - my @fields; - my $payby; - if ( $format eq 'simple' ) { - @fields = qw( cust_pkg.setup dayphone first last - address1 address2 city state zip comments ); - $payby = 'BILL'; - } elsif ( $format eq 'extended' ) { - @fields = qw( agent_custid refnum - last first address1 address2 city state zip country - daytime night - ship_last ship_first ship_address1 ship_address2 - ship_city ship_state ship_zip ship_country - payinfo paycvv paydate - invoicing_list - cust_pkg.pkgpart - svc_acct.username svc_acct._password - ); - $payby = 'BILL'; - } elsif ( $format eq 'extended-plus_company' ) { - @fields = qw( agent_custid refnum - last first company address1 address2 city state zip country - daytime night - ship_last ship_first ship_company ship_address1 ship_address2 - ship_city ship_state ship_zip ship_country - payinfo paycvv paydate - invoicing_list - cust_pkg.pkgpart - svc_acct.username svc_acct._password - ); - $payby = 'BILL'; - } else { - die "unknown format $format"; - } - - eval "use Text::CSV_XS;"; - die $@ if $@; - - my $csv = new Text::CSV_XS; - #warn $csv; - #warn $fh; - - my $imported = 0; - #my $columns; - - 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; - - #while ( $columns = $csv->getline($fh) ) { - my $line; - while ( defined($line=<$fh>) ) { - - $csv->parse($line) or do { - $dbh->rollback if $oldAutoCommit; - return "can't parse: ". $csv->error_input(); - }; - - my @columns = $csv->fields(); - #warn join('-',@columns); - - my %cust_main = ( - agentnum => $agentnum, - refnum => $refnum, - country => $conf->config('countrydefault') || 'US', - payby => $payby, #default - paydate => '12/2037', #default - ); - my $billtime = time; - my %cust_pkg = ( pkgpart => $pkgpart ); - my %svc_acct = (); - foreach my $field ( @fields ) { - - if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) { - - #$cust_pkg{$1} = str2time( shift @$columns ); - if ( $1 eq 'pkgpart' ) { - $cust_pkg{$1} = shift @columns; - } elsif ( $1 eq 'setup' ) { - $billtime = str2time(shift @columns); - } else { - $cust_pkg{$1} = str2time( shift @columns ); - } - - } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) { - - $svc_acct{$1} = shift @columns; - - } else { - - #refnum interception - if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) { - - my $referral = $columns[0]; - my %hash = ( 'referral' => $referral, - 'agentnum' => $agentnum, - 'disabled' => '', - ); - - my $part_referral = qsearchs('part_referral', \%hash ) - || new FS::part_referral \%hash; - - unless ( $part_referral->refnum ) { - my $error = $part_referral->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't auto-insert advertising source: $referral: $error"; - } - } - - $columns[0] = $part_referral->refnum; - } - - #$cust_main{$field} = shift @$columns; - $cust_main{$field} = shift @columns; - } - } - - $cust_main{'payby'} = 'CARD' if length($cust_main{'payinfo'}); - - my $invoicing_list = $cust_main{'invoicing_list'} - ? [ delete $cust_main{'invoicing_list'} ] - : []; - - my $cust_main = new FS::cust_main ( \%cust_main ); - - use Tie::RefHash; - tie my %hash, 'Tie::RefHash'; #this part is important - - if ( $cust_pkg{'pkgpart'} ) { - my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ); - - my @svc_acct = (); - if ( $svc_acct{'username'} ) { - my $part_pkg = $cust_pkg->part_pkg; - unless ( $part_pkg ) { - $dbh->rollback if $oldAutoCommit; - return "unknown pkgpart: ". $cust_pkg{'pkgpart'}; - } - $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' ); - push @svc_acct, new FS::svc_acct ( \%svc_acct ) - } - - $hash{$cust_pkg} = \@svc_acct; - } - - my $error = $cust_main->insert( \%hash, $invoicing_list ); - - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't insert customer for $line: $error"; - } - - if ( $format eq 'simple' ) { - - #false laziness w/bill.cgi - $error = $cust_main->bill( 'time' => $billtime ); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't bill customer for $line: $error"; - } - - $error = $cust_main->apply_payments_and_credits; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't bill customer for $line: $error"; - } - - $error = $cust_main->collect(); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't collect customer for $line: $error"; - } - - } - - $imported++; - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - - return "Empty file!" unless $imported; - - ''; #no error - -} - =item batch_charge =cut @@ -6079,18 +6812,19 @@ I<$expdate> - the expiration of the customer payment in seconds from epoch =cut sub notify { - my ($customer, $template, %options) = @_; + my ($self, $template, %options) = @_; return unless $conf->exists($template); - my $from = $conf->config('invoice_from') if $conf->exists('invoice_from'); + my $from = $conf->config('invoice_from', $self->agentnum) + if $conf->exists('invoice_from', $self->agentnum); $from = $options{from} if exists($options{from}); - my $to = join(',', $customer->invoicing_list_emailonly); + my $to = join(',', $self->invoicing_list_emailonly); $to = $options{to} if exists($options{to}); - my $subject = "Notice from " . $conf->config('company_name') - if $conf->exists('company_name'); + my $subject = "Notice from " . $conf->config('company_name', $self->agentnum) + if $conf->exists('company_name', $self->agentnum); $subject = $options{subject} if exists($options{subject}); my $notify_template = new Text::Template (TYPE => 'ARRAY', @@ -6101,16 +6835,17 @@ sub notify { $notify_template->compile() or die "can't compile template: Text::Template::ERROR"; - $FS::notify_template::_template::company_name = $conf->config('company_name'); + $FS::notify_template::_template::company_name = + $conf->config('company_name', $self->agentnum); $FS::notify_template::_template::company_address = - join("\n", $conf->config('company_address') ). "\n"; - - my $paydate = $customer->paydate || '2037-12-31'; - $FS::notify_template::_template::first = $customer->first; - $FS::notify_template::_template::last = $customer->last; - $FS::notify_template::_template::company = $customer->company; - $FS::notify_template::_template::payinfo = $customer->mask_payinfo; - my $payby = $customer->payby; + join("\n", $conf->config('company_address', $self->agentnum) ). "\n"; + + my $paydate = $self->paydate || '2037-12-31'; + $FS::notify_template::_template::first = $self->first; + $FS::notify_template::_template::last = $self->last; + $FS::notify_template::_template::company = $self->company; + $FS::notify_template::_template::payinfo = $self->mask_payinfo; + my $payby = $self->payby; my ($payyear,$paymonth,$payday) = split (/-/,$paydate); my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear); @@ -6210,10 +6945,10 @@ sub generate_letter { ); if ( length($retadd) ) { $letter_data{returnaddress} = $retadd; - } elsif ( grep /\S/, $conf->config('company_address') ) { + } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) { $letter_data{returnaddress} = join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg, - $conf->config('company_address') + $conf->config('company_address', $self->agentnum) ); } else { $letter_data{returnaddress} = '~'; @@ -6222,9 +6957,9 @@ sub generate_letter { $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc"; - $letter_data{company_name} = $conf->config('company_name'); + $letter_data{company_name} = $conf->config('company_name', $self->agentnum); - my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc; + my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc; my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX', DIR => $dir, SUFFIX => '.tex', @@ -6272,6 +7007,8 @@ sub print { do_print [ $self->print_ps($template) ]; } +#these three subs should just go away once agent stuff is all config overrides + sub agent_template { my $self = shift; $self->_agent_plandata('agent_templatename'); @@ -6312,9 +7049,13 @@ sub _agent_plandata { AND peo_agentnum.optionname = 'agentnum' AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)' ) - LEFT JOIN part_event_option AS peo_cust_bill_age - ON ( part_event.eventpart = peo_cust_bill_age.eventpart - AND peo_cust_bill_age.optionname = 'cust_bill_age' + LEFT JOIN part_event_condition + ON ( part_event.eventpart = part_event_condition.eventpart + AND part_event_condition.conditionname = 'cust_bill_age' + ) + LEFT JOIN part_event_condition_option + ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum + AND part_event_condition_option.optionname = 'age' ) }, #'hashref' => { 'optionname' => $option }, @@ -6324,11 +7065,11 @@ sub _agent_plandata { " AND action = 'cust_bill_send_agent' ". " AND ( disabled IS NULL OR disabled != 'Y' ) ". " AND peo_agentnum.optionname = 'agentnum' ". - " AND agentnum IS NULL OR agentnum = $agentnum ". + " AND ( agentnum IS NULL OR agentnum = $agentnum ) ". " ORDER BY - CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age' + CASE WHEN part_event_condition_option.optionname IS NULL THEN -1 - ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue'). + ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue'). " END , part_event.weight". " LIMIT 1"