diff options
Diffstat (limited to 'FS/FS/cust_main.pm')
-rw-r--r-- | FS/FS/cust_main.pm | 750 |
1 files changed, 623 insertions, 127 deletions
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 7238e97..fb64fa3 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -1,5 +1,6 @@ package FS::cust_main; +require 5.006; use strict; use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields $import $skip_fuzzyfiles $ignore_expired_card @paytypes); @@ -7,13 +8,9 @@ use vars qw( $realtime_bop_decline_quiet ); #ugh use Safe; use Carp; use Exporter; -BEGIN { - eval "use Time::Local;"; - die "Time::Local minimum version 1.05 required with Perl versions before 5.6" - if $] < 5.006 && !defined($Time::Local::VERSION); - #eval "use Time::Local qw(timelocal timelocal_nocheck);"; - eval "use Time::Local qw(timelocal_nocheck);"; -} +use Time::Local qw(timelocal_nocheck); +use Data::Dumper; +use Tie::IxHash; use Digest::MD5 qw(md5_base64); use Date::Format; use Date::Parse; @@ -32,6 +29,7 @@ use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_pay; use FS::cust_pay_void; +use FS::cust_pay_batch; use FS::cust_credit; use FS::cust_refund; use FS::part_referral; @@ -43,8 +41,9 @@ use FS::cust_bill_pay; use FS::prepay_credit; use FS::queue; use FS::part_pkg; -use FS::part_bill_event qw(due_events); -use FS::cust_bill_event; +use FS::part_event; +use FS::part_event_condition; +#use FS::cust_event; use FS::cust_tax_exempt; use FS::cust_tax_exempt_pkg; use FS::type_pkgs; @@ -1423,11 +1422,10 @@ sub check { $payinfo =~ s/[^\d\@]//g; if ( $conf->exists('echeck-nonus') ) { $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba'; - $payinfo = "$1\@$2"; } else { $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba'; - $payinfo = "$1\@$2"; } + $payinfo = "$1\@$2"; $self->payinfo($payinfo); $self->paycvv(''); @@ -1547,6 +1545,16 @@ sub all_pkgs { sort sort_packages @cust_pkg; } +=item cust_pkg + +Synonym for B<all_pkgs>. + +=cut + +sub cust_pkg { + shift->all_pkgs(@_); +} + =item ncancelled_pkgs Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer. @@ -1561,11 +1569,18 @@ sub ncancelled_pkgs { my @cust_pkg = (); if ( $self->{'_pkgnum'} ) { + warn "$me ncancelled_pkgs: returning cached objects" + if $DEBUG > 1; + @cust_pkg = grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache }; } else { + warn "$me ncancelled_pkgs: searching for packages for custnum ". + $self->custnum + if $DEBUG > 1; + @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum, @@ -1683,10 +1698,20 @@ sub suspend { grep { $_->suspend(@_) } $self->unsuspended_pkgs; } -=item suspend_if_pkgpart PKGPART [ , PKGPART ... ] +=item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ] Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed -PKGPARTs (see L<FS::part_pkg>). +PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref instead +of a list of pkgparts; the hashref has the following keys: + +=over 4 + +=item pkgparts - listref of pkgparts + +=item (other options are passed to the suspend method) + +=back + Returns a list: an empty list on success or a list of errors. @@ -1706,10 +1731,19 @@ sub suspend_if_pkgpart { $self->unsuspended_pkgs; } -=item suspend_unless_pkgpart PKGPART [ , PKGPART ... ] +=item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ] Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the -listed PKGPARTs (see L<FS::part_pkg>). +given PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref +instead of a list of pkgparts; the hashref has the following keys: + +=over 4 + +=item pkgparts - listref of pkgparts + +=item (other options are passed to the suspend method) + +=back Returns a list: an empty list on success or a list of errors. @@ -1733,22 +1767,31 @@ sub suspend_unless_pkgpart { Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer. -Available options are: I<quiet>, I<reasonnum>, and I<ban> +Available options are: -I<quiet> can be set true to supress email cancellation notices. +=over 4 -# I<reasonnum> can be set to a cancellation reason (see L<FS::cancel_reason>) +=item quiet - can be set true to supress email cancellation notices. -I<ban> can be set true to ban this customer's credit card or ACH information, -if present. +=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 ban - can be set true to ban this customer's credit card or ACH information, if present. + +=back Always returns a list: an empty list on success or a list of errors. =cut sub cancel { - my $self = shift; - my %opt = @_; + my( $self, %opt ) = @_; + + warn "$me cancel called on customer ". $self->custnum. " with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" + if $DEBUG; + + return ( 'access denied' ) + unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) { @@ -1763,7 +1806,13 @@ sub cancel { } - grep { $_ } map { $_->cancel(@_) } $self->ncancelled_pkgs; + my @pkgs = $self->ncancelled_pkgs; + + warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/". + scalar(@pkgs). " packages for customer ". $self->custnum. "\n" + if $DEBUG; + + grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs; } sub _banned_pay_hashref { @@ -1810,10 +1859,87 @@ sub agent { qsearchs( 'agent', { 'agentnum' => $self->agentnum } ); } +=item bill_and_collect + +Cancels and suspends any packages due, generates bills, applies payments and +cred + +Warns on errors (Does not currently: 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 time - bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example: + + use Date::Parse; + ... + $cust_main->bill( 'time' => str2time('April 20th, 2001') ); + +=item invoice_time - used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected. + +=item check_freq - "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq) + +=item resetup - if set true, re-charges setup fees. + +=back + +=cut + +sub bill_and_collect { + my( $self, %options ) = @_; + + ### + # cancel packages + ### + + #$^T not $options{time} because freeside-daily -d is for pre-printing invoices + foreach my $cust_pkg ( + grep { $_->expire && $_->expire <= $^T } $self->ncancelled_pkgs + ) { + my $error = $cust_pkg->cancel; + warn "Error cancelling expired pkg ". $cust_pkg->pkgnum. + " for custnum ". $self->custnum. ": $error" + if $error; + } + + ### + # suspend packages + ### + + #$^T not $options{time} because freeside-daily -d is for pre-printing invoices + foreach my $cust_pkg ( + grep { ( $_->part_pkg->is_prepaid && $_->bill && $_->bill < $^T + || $_->adjourn && $_->adjourn <= $^T + ) + && ! $_->susp + } + $self->ncancelled_pkgs + ) { + my $error = $cust_pkg->suspend; + warn "Error suspending package ". $cust_pkg->pkgnum. + " for custnum ". $self->custnum. ": $error" + if $error; + } + + ### + # bill and collect + ### + + my $error = $self->bill( %options ); + warn "Error billing, custnum ". $self->custnum. ": $error" if $error; + + $self->apply_payments_and_credits; + + $error = $self->collect( %options ); + warn "Error collecting, custnum". $self->custnum. ": $error" if $error; + +} + =item bill OPTIONS Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in -conjunction with the collect method. +conjunction with the collect method by calling B<bill_and_collect>. If there is an error, returns the error, otherwise returns false. @@ -1829,6 +1955,10 @@ Options are passed as name-value pairs. Currently available options are: ... $cust_main->bill( 'time' => str2time('April 20th, 2001') ); +=item pkg_list - An array ref of specific packages (objects) to attempt billing, instead trying all of them. + + $cust_main->bill( pkg_list => [$pkg1, $pkg2] ); + =item invoice_time - used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected. =back @@ -1940,6 +2070,13 @@ sub bill { ( $cust_pkg->getfield('bill') || 0 ) <= $time ) { + # XXX should this be a package event? probably. events are called + # at collection time at the moment, though... + if ( $part_pkg->can('reset_usage') ) { + warn " resetting usage counters" if $DEBUG > 1; + $part_pkg->reset_usage($cust_pkg); + } + warn " bill recur\n" if $DEBUG > 1; # XXX shared with $recur_prog @@ -2190,6 +2327,18 @@ sub bill { unless ( $cust_bill->cust_bill_pkg ) { $cust_bill->delete; #don't create an invoice w/o line items + + # XXX this seems to be broken + #( DBD::Pg::st execute failed: ERROR: syntax error at or near "hcb" ) +# # get rid of our fake history too, waste of unecessary space +# my $h_cleanup_query = q{ +# DELETE FROM h_cust_bill hcb +# WHERE hcb.invnum = ? +# AND NOT EXISTS ( SELECT 1 FROM cust_bill cb where cb.invnum = hcb.invnum ) +# }; +# my $h_sth = $dbh->prepare($h_cleanup_query); +# $h_sth->execute($invnum); + $dbh->commit or die $dbh->errstr if $oldAutoCommit; return ''; } @@ -2244,12 +2393,9 @@ sub bill { (Attempt to) collect money for this customer's outstanding invoices (see L<FS::cust_bill>). Usually used after the bill method. -Depending on the value of `payby', this may print or email an invoice (I<BILL>, -I<DCRD>, or I<DCHK>), charge a credit card (I<CARD>), charge via electronic -check/ACH (I<CHEK>), or just add any necessary (pseudo-)payment (I<COMP>). - -Most actions are now triggered by invoice events; see L<FS::part_bill_event> -and the invoice events web interface. +Actions are now triggered by billing events; see L<FS::part_event> and the +billing events web interface. Old-style invoice events (see +L<FS::part_bill_event>) have been deprecated. If there is an error, returns the error, otherwise returns false. @@ -2257,19 +2403,17 @@ Options are passed as name-value pairs. Currently available options are: -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<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> -for conversion functions. +=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<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. -retry - Retry card/echeck/LEC transactions even when not scheduled by invoice -events. +=item retry - Retry card/echeck/LEC transactions even when not scheduled by invoice events. -quiet - set true to surpress email card/ACH decline notices. +=item quiet - set true to surpress email card/ACH decline notices. -freq - "1d" for the traditional, daily events (the default), or "1m" for the -new monthly events +=item check_freq - "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq) -payby - allows for one time override of normal customer billing method +=item payby - allows for one time override of normal customer billing method =cut @@ -2291,12 +2435,9 @@ sub collect { $self->select_for_update; #mutex - my $balance = $self->balance; - warn "$me collect customer ". $self->custnum. ": balance $balance\n" - if $DEBUG; - unless ( $balance > 0 ) { #redundant????? - $dbh->rollback if $oldAutoCommit; #hmm - return ''; + if ( $DEBUG ) { + my $balance = $self->balance; + warn "$me collect customer ". $self->custnum. ": balance $balance\n" } if ( exists($options{'retry_card'}) ) { @@ -2311,51 +2452,233 @@ sub collect { } } - my $extra_sql = ''; - if ( defined $options{'freq'} && $options{'freq'} eq '1m' ) { - $extra_sql = " AND freq = '1m' "; - } else { - $extra_sql = " AND ( freq = '1d' OR freq IS NULL OR freq = '' ) "; - } - - foreach my $cust_bill ( $self->open_cust_bill ) { - - # don't try to charge for the same invoice if it's already in a batch - #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } ); + # false laziness w/pay_batch::import_results - last if $self->balance <= 0; - - warn " invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")\n" - if $DEBUG > 1; + my $due_cust_event = $self->due_cust_event( + 'time' => $invoice_time, + 'check_freq' => $options{'check_freq'}, + ); + unless( ref($due_cust_event) ) { + $dbh->rollback if $oldAutoCommit; + return $due_cust_event; + } - foreach my $part_bill_event ( due_events ( $cust_bill, - exists($options{'payby'}) - ? $options{'payby'} - : $self->payby, - $invoice_time, - $extra_sql ) ) { + foreach my $cust_event ( @$due_cust_event ) { - last if $cust_bill->owed <= 0 # don't run subsequent events if owed<=0 - || $self->balance <= 0; # or if balance<=0 + #XXX lock event + + #re-eval event conditions (a previous event could have changed things) + next unless $cust_event->test_conditions( 'time' => $invoice_time ); - { - local $realtime_bop_decline_quiet = 1 if $options{'quiet'}; - warn " do_event " . $cust_bill . " ". (%options) . "\n" - if $DEBUG > 1; + { + local $realtime_bop_decline_quiet = 1 if $options{'quiet'}; + warn " running cust_event ". $cust_event->eventnum. "\n" + if $DEBUG > 1; - if (my $error = $part_bill_event->do_event($cust_bill, %options)) { + + #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options? + if ( my $error = $cust_event->do_event() ) { + #XXX wtf is this? figure out a proper dealio with return value + #from do_event # gah, even with transactions. $dbh->commit if $oldAutoCommit; #well. return $error; } + } + + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + +=item due_cust_event [ HASHREF | OPTION => VALUE ... ] + +Inserts database records for and returns an ordered listref of new events due +for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no +events are due, an empty listref is returned. If there is an error, returns a +scalar error message. + +To actually run the events, call each event's test_condition method, and if +still true, call the event's do_event method. + +Options are passed as a hashref or as a list of name-value pairs. Available +options are: + +=over 4 + +=item check_freq - Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized. + +=item time - "Current time" for the events. + +=item debug - Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), or 3 (more information) + +=item eventtable - Only return events for the specified eventtable (by default, events of all eventtables are returned) + +=item objects - Explicitly pass the objects to be tested (typically used with eventtable). + +=back + +=cut + +sub due_cust_event { + my $self = shift; + my %opt = ref($_[0]) ? %{ $_[0] } : @_; + + #??? + #my $DEBUG = $opt{'debug'} + local($DEBUG) = $opt{'debug'} + if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG; + + warn "$me due_cust_event called with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt). "\n" + if $DEBUG; + + $opt{'time'} ||= time; + + 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; + + $self->select_for_update; #mutex + + ### + # 1: find possible events (initial search) + ### + + my @cust_event = (); + + my @eventtable = $opt{'eventtable'} + ? ( $opt{'eventtable'} ) + : FS::part_event->eventtables_runorder; + + foreach my $eventtable ( @eventtable ) { + + my @objects; + if ( $opt{'objects'} ) { + + @objects = @{ $opt{'objects'} }; + + } else { + + #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; } + @objects = ( $eventtable eq 'cust_main' ) + ? ( $self ) + : ( $self->$eventtable() ); + + } + + my @e_cust_event = (); + + my $cross = "CROSS JOIN $eventtable"; + $cross .= ' LEFT JOIN cust_main USING ( custnum )' + unless $eventtable eq 'cust_main'; + + foreach my $object ( @objects ) { + + #this first search uses the condition_sql magic for optimization. + #the more possible events we can eliminate in this step the better + + my $cross_where = ''; + my $pkey = $object->primary_key; + $cross_where = "$eventtable.$pkey = ". $object->$pkey(); + + my $join = FS::part_event_condition->join_conditions_sql( $eventtable ); + my $extra_sql = + FS::part_event_condition->where_conditions_sql( $eventtable, + 'time'=>$opt{'time'} + ); + my $order = FS::part_event_condition->order_conditions_sql( $eventtable ); + + $extra_sql = "AND $extra_sql" if $extra_sql; + + #here is the agent virtualization + $extra_sql .= " AND ( part_event.agentnum IS NULL + OR part_event.agentnum = ". $self->agentnum. ' )'; + + $extra_sql .= " $order"; + + my @part_event = qsearch( { + 'select' => 'part_event.*', + 'table' => 'part_event', + 'addl_from' => "$cross $join", + 'hashref' => { 'check_freq' => ( $opt{'check_freq'} || '1d' ), + 'eventtable' => $eventtable, + 'disabled' => '', + }, + 'extra_sql' => "AND $cross_where $extra_sql", + } ); + + if ( $DEBUG > 2 ) { + my $pkey = $object->primary_key; + warn " ". scalar(@part_event). + " possible events found for $eventtable ". $object->$pkey(). "\n"; } + push @e_cust_event, map { $_->new_cust_event($object) } @part_event; + } + warn " ". scalar(@e_cust_event). + " subtotal possible cust events found for $eventtable" + if $DEBUG > 1; + + push @cust_event, @e_cust_event; + + } + + warn " ". scalar(@cust_event). + " total possible cust events found in initial search\n" + if $DEBUG; # > 1; + + ## + # 2: test conditions + ## + + my %unsat = (); + + @cust_event = grep $_->test_conditions( 'time' => $opt{'time'}, + 'stats_hashref' => \%unsat ), + @cust_event; + + warn " ". scalar(@cust_event). " cust events left satisfying conditions\n" + if $DEBUG; # > 1; + + warn " invalid conditions not eliminated with condition_sql:\n". + join('', map " $_: ".$unsat{$_}."\n", keys %unsat ); + + ## + # 3: insert + ## + + foreach my $cust_event ( @cust_event ) { + + my $error = $cust_event->insert(); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; + + ## + # 4: return + ## + + warn " returning events: ". Dumper(@cust_event). "\n" + if $DEBUG > 2; + + \@cust_event; } @@ -2366,9 +2689,9 @@ events for for retry. Useful if card information has changed or manual retry is desired. The 'collect' method must be called to actually retry the transaction. -Implementation details: For each of this customer's open invoices, changes -the status of the first "done" (with statustext error) realtime processing -event to "failed". +Implementation details: For either this customer, or for each of this +customer's open invoices, changes the status of the first "done" (with +statustext error) realtime processing event to "failed". =cut @@ -2386,25 +2709,52 @@ sub retry_realtime { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - foreach my $cust_bill ( - grep { $_->cust_bill_event } - $self->open_cust_bill - ) { - my @cust_bill_event = - sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds } - grep { - #$_->part_bill_event->plan eq 'realtime-card' - $_->part_bill_event->eventcode =~ - /\$cust_bill\->(batch|realtime)_(card|ach|lec)/ - && $_->status eq 'done' - && $_->statustext - } - $cust_bill->cust_bill_event; - next unless @cust_bill_event; - my $error = $cust_bill_event[0]->retry; + #a little false laziness w/due_cust_event (not too bad, really) + + my $join = FS::part_event_condition->join_conditions_sql; + my $order = FS::part_event_condition->order_conditions_sql; + + #here is the agent virtualization + my $agent_virt = " ( part_event.agentnum IS NULL + OR part_event.agentnum = ". $self->agentnum. ' )'; + + #XXX this shouldn't be hardcoded, actions should declare it... + my @realtime_events = qw( + cust_bill_realtime_card + cust_bill_realtime_check + cust_bill_realtime_lec + cust_bill_batch + ); + + my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'", + @realtime_events + ). + ' ) '; + + my @cust_event = qsearchs({ + 'table' => 'cust_event', + 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join", + 'hashref' => { 'status' => 'done' }, + 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ". + " AND $is_realtime_event AND $agent_virt $order" # LIMIT 1" + }); + + my %seen_invnum = (); + foreach my $cust_event (@cust_event) { + + #max one for the customer, one for each open invoice + my $cust_X = $cust_event->cust_X; + next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill' + ? $cust_X->invnum + : 0 + }++ + or $cust_event->part_event->eventtable eq 'cust_bill' + && ! $cust_X->owed; + + my $error = $cust_event->retry; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "error scheduling invoice event for retry: $error"; + return "error scheduling event for retry: $error"; } } @@ -2457,6 +2807,22 @@ sub realtime_bop { ? $options{'payinfo'} : $self->payinfo; + my %method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', + ); + + ### + # check for banned credit card/ACH + ### + + my $ban = qsearchs('banned_pay', { + 'payby' => $method2payby{$method}, + 'payinfo' => md5_base64($payinfo), + } ); + return "Banned credit card" if $ban; + ### # select a gateway ### @@ -3596,17 +3962,38 @@ sub total_unapplied_payments { sprintf( "%.2f", $total_unapplied ); } +=item total_unapplied_refunds + +Returns the total unrefunded refunds (see L<FS::cust_refund>) for this +customer. See L<FS::cust_refund/unapplied>. + +=cut + +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; + } + sprintf( "%.2f", $total_unapplied ); +} + =item balance -Returns the balance for this customer (total_owed minus total_credited -minus total_unapplied_payments). +Returns the balance for this customer (total_owed plus total_unrefunded, minus +total_credited minus total_unapplied_payments). =cut sub balance { my $self = shift; sprintf( "%.2f", - $self->total_owed - $self->total_credited - $self->total_unapplied_payments + $self->total_owed + + $self->total_unapplied_refunds + - $self->total_credited + - $self->total_unapplied_payments ); } @@ -3624,7 +4011,8 @@ sub balance_date { my $self = shift; my $time = shift; sprintf( "%.2f", - $self->total_owed_date($time) + $self->total_owed_date($time) + + $self->total_unapplied_refunds - $self->total_credited - $self->total_unapplied_payments ); @@ -4068,6 +4456,17 @@ sub cust_pay_void { qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } ) } +=item cust_pay_batch + +Returns all batched payments (see L<FS::cust_pay_void>) for this customer. + +=cut + +sub cust_pay_batch { + my $self = shift; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } ) +} =item cust_refund @@ -4206,13 +4605,13 @@ Returns a hex triplet color string for this customer's status. =cut use vars qw(%statuscolor); -%statuscolor = ( +tie my %statuscolor, 'Tie::IxHash', 'prospect' => '7e0079', #'000000', #black? naw, purple 'active' => '00CC00', #green 'inactive' => '0000CC', #blue 'suspended' => 'FF9900', #yellow 'cancelled' => 'FF0000', #red -); +; sub statuscolor { shift->cust_statuscolor(@_); } @@ -4227,6 +4626,20 @@ sub cust_statuscolor { =over 4 +=item statuses + +Class method that returns the list of possible status strings for customers +(see L<the status method|/status>). For example: + + @statuses = FS::cust_main->statuses(); + +=cut + +sub statuses { + #my $self = shift; #could be class... + keys %statuscolor; +} + =item prospect_sql Returns an SQL expression identifying prospective cust_main records (customers @@ -4329,6 +4742,65 @@ sub uncancel_sql { " ) "; } +=item balance_sql + +Returns an SQL fragment to retreive the balance. + +=cut + +sub balance_sql { " + COALESCE( ( SELECT SUM(charged) FROM cust_bill + WHERE cust_bill.custnum = cust_main.custnum ), 0) + - COALESCE( ( SELECT SUM(paid) FROM cust_pay + WHERE cust_pay.custnum = cust_main.custnum ), 0) + - COALESCE( ( SELECT SUM(amount) FROM cust_credit + WHERE cust_credit.custnum = cust_main.custnum ), 0) + + COALESCE( ( SELECT SUM(refund) FROM cust_refund + WHERE cust_refund.custnum = cust_main.custnum ), 0) +"; } + +=item balance_date_sql TIME + +Returns an SQL fragment to retreive the balance for this customer, only +considering invoices with date earlier than TIME. (total_owed_date minus total_credited minus +total_unapplied_payments). TIME is specified as an SQL fragment or a numeric +UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and +L<Date::Parse> for conversion functions. + +=cut + +sub balance_date_sql { + my( $class, $time ) = @_; + + my $owed_sql = FS::cust_bill->owed_sql; + my $unapp_refund_sql = FS::cust_refund->unapplied_sql; + #my $unapp_credit_sql = FS::cust_credit->unapplied_sql; + my $unapp_credit_sql = FS::cust_credit->credited_sql; + my $unapp_pay_sql = FS::cust_pay->unapplied_sql; + + " + COALESCE( ( SELECT SUM($owed_sql) FROM cust_bill + WHERE cust_bill.custnum = cust_main.custnum + AND cust_bill._date <= $time ) + ,0 + ) + + COALESCE( ( SELECT SUM($unapp_refund_sql) FROM cust_refund + WHERE cust_refund.custnum = cust_main.custnum ) + ,0 + ) + - COALESCE( ( SELECT SUM($unapp_credit_sql) FROM cust_credit + WHERE cust_credit.custnum = cust_main.custnum ) + ,0 + ) + - COALESCE( ( SELECT SUM($unapp_pay_sql) FROM cust_pay + WHERE cust_pay.custnum = cust_main.custnum ) + ,0 + ) + + "; + +} + =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ] Performs a fuzzy (approximate) search and returns the matching FS::cust_main @@ -4552,9 +5024,10 @@ sub smart_search { 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization } ); - #always do substring & fuzzy, - #getting complains searches are not returning enough - unless ( @cust_main && $skip_fuzzy ) { #no exact match, trying substring/fuzzy + #no exact match, trying substring/fuzzy + #always do substring & fuzzy (unless they're explicity config'ed off) + #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 @@ -5158,7 +5631,7 @@ sub generate_letter { unless(exists($letter_data{returnaddress})){ my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress', - $self->_agent_template) + $self->agent_template) ); $letter_data{returnaddress} = length($retadd) ? $retadd : '~'; @@ -5227,32 +5700,53 @@ sub agent_invoice_from { sub _agent_plandata { my( $self, $option ) = @_; - my $part_bill_event = qsearchs( 'part_bill_event', - { - 'payby' => $self->payby, - 'plan' => 'send_agent', - 'plandata' => { 'op' => '~', - 'value' => "(^|\n)agentnum ". - '([0-9]*, )*'. - $self->agentnum. - '(, [0-9]*)*'. - "(\n|\$)", - }, - }, - '', - 'ORDER BY seconds LIMIT 1' - ); - - return '' unless $part_bill_event; - - if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) { - return $1; - } else { - warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart. - " plandata for $option"; + #yuck. this whole thing needs to be reconciled better with 1.9's idea of + #agent-specific Conf + + my $agentnum = $self->agentnum; + + my $part_event_option = + qsearchs({ + 'table' => 'part_event_option', + 'addl_from' => q{ + LEFT JOIN part_event USING ( eventpart ) + LEFT JOIN part_event_option AS peo_agentnum + ON ( part_event.eventpart = peo_agentnum.eventpart + AND peo_agentnum.optionname = 'agentnum' + AND peo_agentnum.optionvalue ~ '(^|,)agentnum(,|$)' + ) + 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' + ) + }, + #'hashref' => { 'optionname' => $option }, + 'hashref' => { 'part_event_option.optionname' => $option }, + 'extra_sql' => " AND event = 'cust_bill_send_agent' ". + " AND peo_agentnum.optionname = 'agentnum' ". + " AND agentnum IS NULL OR agentnum = $agentnum ". + " ORDER BY + CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age' + THEN -1 + ELSE EXTRACT( EPOCH FROM + REPLACE( peo_cust_bill_age.optionvalue, + 'm', + 'mon' + )::interval + ) + END + , part_event.weight". + " LIMIT 1" + }); + + unless ( $part_event_option ) { + return $self->agent->invoice_template || '' + if $option eq '$agent_templatename'; return ''; } + $part_event_option->optionvalue; + } =back @@ -5279,6 +5773,8 @@ Birthdates rely on negative epoch values. The payby for card/check batches is broken. With mixed batching, bad things will happen. +B<collect> I<invoice_time> should be renamed I<time>, like B<bill>. + =head1 SEE ALSO L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit> |