X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=2755be0ae3948746c1bf7562a46d724dd283af63;hb=9896275b96170e2a97e313e64c7aa5bfaf12a087;hp=449ab74b99a3cb1360ec823da18bc8348d8f13f0;hpb=e324bf00eaee9bd13702348b777642d7096b88a0;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 449ab74b9..09424ba52 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,73 +1,46 @@ package FS::cust_bill; +use base qw( FS::cust_bill::Search FS::Template_Mixin + FS::cust_main_Mixin FS::Record + ); use strict; -use vars qw( @ISA $conf $money_char ); -use vars qw( $lpr $invoice_from $smtpmachine ); -use vars qw( $processor ); -use vars qw( $xaction $E_NoErr ); -use vars qw( $bop_processor $bop_login $bop_password $bop_action @bop_options ); -use vars qw( $invoice_lines @buf ); #yuck +use vars qw( $DEBUG $me ); + # but NOT $conf +use Carp; +use Fcntl qw(:flock); #for spool_csv +use Cwd; +use List::Util qw(min max sum); use Date::Format; -use Mail::Internet; -use Mail::Header; -use Text::Template; -use FS::Record qw( qsearch qsearchs ); -use FS::cust_main; +use File::Temp 0.14; +use HTML::Entities; +use Storable qw( freeze thaw ); +use GD::Barcode; +use FS::UID qw( datasrc ); +use FS::Misc qw( send_fax do_print ); +use FS::Record qw( qsearch qsearchs dbh ); +use FS::cust_statement; use FS::cust_bill_pkg; +use FS::cust_bill_pkg_display; +use FS::cust_bill_pkg_detail; use FS::cust_credit; use FS::cust_pay; use FS::cust_pkg; use FS::cust_credit_bill; -use FS::cust_pay_batch; -use FS::cust_bill_event; - -@ISA = qw( FS::Record ); - -#ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_bill'} = sub { - - $conf = new FS::Conf; - - $money_char = $conf->config('money_char') || '$'; - - $lpr = $conf->config('lpr'); - $invoice_from = $conf->config('invoice_from'); - $smtpmachine = $conf->config('smtpmachine'); - - if ( $conf->exists('cybercash3.2') ) { - require CCMckLib3_2; - #qw($MCKversion %Config InitConfig CCError CCDebug CCDebug2); - require CCMckDirectLib3_2; - #qw(SendCC2_1Server); - require CCMckErrno3_2; - #qw(MCKGetErrorMessage $E_NoErr); - import CCMckErrno3_2 qw($E_NoErr); - - my $merchant_conf; - ($merchant_conf,$xaction)= $conf->config('cybercash3.2'); - my $status = &CCMckLib3_2::InitConfig($merchant_conf); - if ( $status != $E_NoErr ) { - warn "CCMckLib3_2::InitConfig error:\n"; - foreach my $key (keys %CCMckLib3_2::Config) { - warn " $key => $CCMckLib3_2::Config{$key}\n" - } - my($errmsg) = &CCMckErrno3_2::MCKGetErrorMessage($status); - die "CCMckLib3_2::InitConfig fatal error: $errmsg\n"; - } - $processor='cybercash3.2'; - } elsif ( $conf->exists('business-onlinepayment') ) { - ( $bop_processor, - $bop_login, - $bop_password, - $bop_action, - @bop_options - ) = $conf->config('business-onlinepayment'); - $bop_action ||= 'normal authorization'; - eval "use Business::OnlinePayment"; - $processor="Business::OnlinePayment::$bop_processor"; - } - -}; +use FS::pay_batch; +use FS::cust_event; +use FS::part_pkg; +use FS::cust_bill_pay; +use FS::payby; +use FS::bill_batch; +use FS::cust_bill_batch; +use FS::cust_bill_pay_pkg; +use FS::cust_credit_bill_pkg; +use FS::discount_plan; +use FS::cust_bill_void; +use FS::L10N; + +$DEBUG = 0; +$me = '[FS::cust_bill]'; =head1 NAME @@ -99,7 +72,7 @@ FS::cust_bill - Object methods for cust_bill records $tax_amount = $record->tax; @lines = $cust_bill->print_text; - @lines = $cust_bill->print_text $time; + @lines = $cust_bill->print_text('time' => $time); =head1 DESCRIPTION @@ -108,6 +81,8 @@ owes you money. The specific charges are itemized as B records (see L). FS::cust_bill inherits from FS::Record. The following fields are currently supported: +Regular fields + =over 4 =item invnum - primary key (assigned automatically for new invoices) @@ -119,10 +94,40 @@ L and L for conversion functions. =item charged - amount of this invoice -=item printed - deprecated +=item invoice_terms - optional terms override for this specific invoice + +=back + +Deprecated fields + +=over 4 + +=item billing_balance - the customer's balance immediately before generating +this invoice. DEPRECATED. Use the L method +to determine the customer's balance at a specific time. + +=item previous_balance - the customer's balance immediately after generating +the invoice before this one. DEPRECATED. + +=item printed - formerly used to track the number of times an invoice had +been printed; no longer used. + +=back + +Specific use cases + +=over 4 =item closed - books closed flag, empty or `Y' +=item statementnum - invoice aggregation (see L) + +=item agent_invid - legacy invoice number + +=item promised_date - customer promised payment date, for collection + +=item pending - invoice is still being generated, empty or 'Y' + =back =head1 METHODS @@ -138,45 +143,246 @@ Invoices are normally created by calling the bill method of a customer object =cut sub table { 'cust_bill'; } +sub template_conf { 'invoice_'; } + +sub has_sections { + my $self = shift; + my $agentnum = $self->cust_main->agentnum; + my $tc = $self->template_conf; + + $self->conf->exists($tc.'sections', $agentnum) || + $self->conf->exists($tc.'sections_by_location', $agentnum); +} + +# should be the ONLY occurrence of "Invoice" in invoice rendering code. +# (except email_subject and invnum_date_pretty) +sub notice_name { + my $self = shift; + $self->conf->config('notice_name') || 'Invoice' +} + +sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } +sub cust_unlinked_msg { + my $self = shift; + "WARNING: can't find cust_main.custnum ". $self->custnum. + ' (cust_bill.invnum '. $self->invnum. ')'; +} =item insert Adds this invoice to the database ("Posts" the invoice). If there is an error, returns the error, otherwise returns false. -=item delete +=cut + +sub insert { + my $self = shift; + warn "$me insert called\n" if $DEBUG; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = $self->SUPER::insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + if ( $self->get('cust_bill_pkg') ) { + foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) { + $cust_bill_pkg->invnum($self->invnum); + my $error = $cust_bill_pkg->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "can't create invoice line item: $error"; + } + } + } -Currently unimplemented. I don't remove invoices because there would then be -no record you ever posted this invoice (which is bad, no?) + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + +=item void + +Voids this invoice: deletes the invoice and adds a record of the voided invoice +to the FS::cust_bill_void table (and related tables starting from +FS::cust_bill_pkg_void). =cut -sub delete { +sub void { + my $self = shift; + my $reason = scalar(@_) ? shift : ''; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $cust_bill_void = new FS::cust_bill_void ( { + map { $_ => $self->get($_) } $self->fields + } ); + $cust_bill_void->reason($reason); + my $error = $cust_bill_void->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + my $error = $cust_bill_pkg->void($reason); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + $error = $self->_delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + ''; + +} + +# removed docs entirely and renamed method to _delete to further indicate it is +# internal-only and discourage use +# +# =item delete +# +# DO NOT USE THIS METHOD. Instead, apply a credit against the invoice, or use +# the B method. +# +# This is only for internal use by V, which is what you should be using. +# +# DO NOT USE THIS METHOD. Whatever reason you think you have is almost certainly +# wrong. Use B, that's what it is for. Really. This means you. +# +# =cut + +sub _delete { my $self = shift; return "Can't delete closed invoice" if $self->closed =~ /^Y/i; - $self->SUPER::delete(@_); + + 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; + + foreach my $table (qw( + cust_credit_bill + cust_bill_pay_batch + cust_bill_pay + cust_bill_batch + cust_bill_pkg + )) { + #cust_event # problematic + #cust_pay_batch # unnecessary + + foreach my $linked ( $self->$table() ) { + my $error = $linked->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + } + + my $error = $self->SUPER::delete(@_); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + ''; + } -=item replace OLD_RECORD +=item replace [ OLD_RECORD ] -Replaces the OLD_RECORD with this one in the database. If there is an error, -returns the error, otherwise returns false. +You can, but probably shouldn't modify invoices... -Only printed may be changed. printed is normally updated by calling the -collect method of a customer object (see L). +Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not +supplied, replaces this record. If there is an error, returns the error, +otherwise returns false. =cut -sub replace { +#replace can be inherited from Record.pm + +# replace_check is now the preferred way to #implement replace data checks +# (so $object->replace() works without an argument) + +sub replace_check { my( $new, $old ) = ( shift, shift ); - return "Can't change custnum!" unless $old->custnum == $new->custnum; + return "Can't modify closed invoice" if $old->closed =~ /^Y/i; #return "Can't change _date!" unless $old->_date eq $new->_date; - return "Can't change _date!" unless $old->_date == $new->_date; - return "Can't change charged!" unless $old->charged == $new->charged; + return "Can't change _date" unless $old->_date == $new->_date; + return "Can't change charged" unless $old->charged == $new->charged + || $old->pending eq 'Y' + || $old->charged == 0 + || $new->{'Hash'}{'cc_surcharge_replace_hack'}; + + ''; +} - $new->SUPER::replace($old); + +=item add_cc_surcharge + +Giant hack + +=cut + +sub add_cc_surcharge { + my ($self, $pkgnum, $amount) = (shift, shift, shift); + + my $error; + my $cust_bill_pkg = new FS::cust_bill_pkg({ + 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + 'setup' => $amount, + }); + $error = $cust_bill_pkg->insert; + return $error if $error; + + $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1; + $self->charged($self->charged+$amount); + $error = $self->replace; + return $error if $error; + + $self->apply_payments_and_credits; } + =item check Checks all fields to make sure this is a valid invoice. If there is an error, @@ -190,22 +396,58 @@ sub check { my $error = $self->ut_numbern('invnum') - || $self->ut_number('custnum') + || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' ) || $self->ut_numbern('_date') || $self->ut_money('charged') || $self->ut_numbern('printed') || $self->ut_enum('closed', [ '', 'Y' ]) + || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' ) + || $self->ut_numbern('agent_invid') #varchar? + || $self->ut_flag('pending') ; return $error if $error; - return "Unknown customer" - unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); - $self->_date(time) unless $self->_date; $self->printed(0) if $self->printed eq ''; - ''; #no error + $self->SUPER::check; +} + +=item display_invnum + +Returns the displayed invoice number for this invoice: agent_invid if +cust_bill-default_agent_invid is set and it has a value, invnum otherwise. + +=cut + +sub display_invnum { + my $self = shift; + if ( $self->agent_invid + && FS::Conf->new->exists('cust_bill-default_agent_invid') ) { + return $self->agent_invid; + } else { + return $self->invnum; + } +} + +=item previous_bill + +Returns the customer's last invoice before this one. + +=cut + +sub previous_bill { + my $self = shift; + if ( !$self->get('previous_bill') ) { + $self->set('previous_bill', qsearchs({ + 'table' => 'cust_bill', + 'hashref' => { 'custnum' => $self->custnum, + '_date' => { op=>'<', value=>$self->_date } }, + 'order_by' => 'ORDER BY _date DESC LIMIT 1', + }) ); + } + $self->get('previous_bill'); } =item previous @@ -217,13 +459,33 @@ followed by the previous outstanding invoices (as FS::cust_bill objects also). sub previous { my $self = shift; - my $total = 0; - my @cust_bill = sort { $a->_date <=> $b->_date } - grep { $_->owed != 0 && $_->_date < $self->_date } - qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) - ; - foreach ( @cust_bill ) { $total += $_->owed; } - $total, @cust_bill; + # simple memoize; we use this a lot + if (!$self->get('previous')) { + my $total = 0; + my @cust_bill = sort { $a->_date <=> $b->_date } + grep { $_->owed != 0 } + qsearch( 'cust_bill', { 'custnum' => $self->custnum, + #'_date' => { op=>'<', value=>$self->_date }, + 'invnum' => { op=>'<', value=>$self->invnum }, + } ) + ; + foreach ( @cust_bill ) { $total += $_->owed; } + $self->set('previous', [$total, @cust_bill]); + } + return @{ $self->get('previous') }; +} + +=item enable_previous + +Whether to show the 'Previous Charges' section when printing this invoice. +The negation of the 'disable_previous_balance' config setting. + +=cut + +sub enable_previous { + my $self = shift; + my $agentnum = $self->cust_main->agentnum; + !$self->conf->exists('disable_previous_balance', $agentnum); } =item cust_bill_pkg @@ -234,72 +496,192 @@ Returns the line items (see L) for this invoice. sub cust_bill_pkg { my $self = shift; - qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } ); + qsearch( + { 'table' => 'cust_bill_pkg', + 'hashref' => { 'invnum' => $self->invnum }, + 'order_by' => 'ORDER BY billpkgnum', #important? otherwise we could use + # the AUTLOADED FK search. or should + # that default to ORDER by the pkey? + } + ); +} + +=item cust_bill_pkg_pkgnum PKGNUM + +Returns the line items (see L) for this invoice and +specified pkgnum. + +=cut + +sub cust_bill_pkg_pkgnum { + my( $self, $pkgnum ) = @_; + qsearch( + { 'table' => 'cust_bill_pkg', + 'hashref' => { 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + }, + 'order_by' => 'ORDER BY billpkgnum', + } + ); +} + +=item cust_pkg + +Returns the packages (see L) corresponding to the line items for +this invoice. + +=cut + +sub cust_pkg { + my $self = shift; + my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () } + $self->cust_bill_pkg; + my %saw = (); + grep { ! $saw{$_->pkgnum}++ } @cust_pkg; +} + +=item no_auto + +Returns true if any of the packages (or their definitions) corresponding to the +line items for this invoice have the no_auto flag set. + +=cut + +sub no_auto { + my $self = shift; + grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg; +} + +=item open_cust_bill_pkg + +Returns the open line items for this invoice. + +Note that cust_bill_pkg with both setup and recur fees are returned as two +separate line items, each with only one fee. + +=cut + +# modeled after cust_main::open_cust_bill +sub open_cust_bill_pkg { + my $self = shift; + + # grep { $_->owed > 0 } $self->cust_bill_pkg + + my %other = ( 'recur' => 'setup', + 'setup' => 'recur', ); + my @open = (); + foreach my $field ( qw( recur setup )) { + push @open, map { $_->set( $other{$field}, 0 ); $_; } + grep { $_->owed($field) > 0 } + $self->cust_bill_pkg; + } + + @open; } -=item cust_bill_event +=item cust_event -Returns the completed invoice events (see L) for this -invoice. +Returns the new-style customer billing events (see L) for this invoice. =cut -sub cust_bill_event { +#false laziness w/cust_pkg.pm +sub cust_event { my $self = shift; - qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } ); + qsearch({ + 'table' => 'cust_event', + 'addl_from' => 'JOIN part_event USING ( eventpart )', + 'hashref' => { 'tablenum' => $self->invnum }, + 'extra_sql' => " AND eventtable = 'cust_bill' ", + }); } +=item num_cust_event + +Returns the number of new-style customer billing events (see L) for this invoice. + +=cut + +#false laziness w/cust_pkg.pm +sub num_cust_event { + my $self = shift; + my $sql = + "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ". + " WHERE tablenum = ? AND eventtable = 'cust_bill'"; + my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; + $sth->execute($self->invnum) or die $sth->errstr. " executing $sql"; + $sth->fetchrow_arrayref->[0]; +} =item cust_main Returns the customer (see L) for this invoice. +=item suspend + +Suspends all unsuspended packages (see L) for this invoice + +Returns a list: an empty list on success or a list of errors. + =cut -sub cust_main { +sub suspend { my $self = shift; - qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); + + grep { $_->suspend(@_) } + grep {! $_->getfield('cancel') } + $self->cust_pkg; + } -=item cust_credit +=item cust_suspend_if_balance_over AMOUNT -Depreciated. See the cust_credited method. +Suspends the customer associated with this invoice if the total amount owed on +this invoice and all older invoices is greater than the specified amount. - #Returns a list consisting of the total previous credited (see - #L) and unapplied for this customer, followed by the previous - #outstanding credits (FS::cust_credit objects). +Returns a list: an empty list on success or a list of errors. =cut -sub cust_credit { - use Carp; - croak "FS::cust_bill->cust_credit depreciated; see ". - "FS::cust_bill->cust_credit_bill"; - #my $self = shift; - #my $total = 0; - #my @cust_credit = sort { $a->_date <=> $b->_date } - # grep { $_->credited != 0 && $_->_date < $self->_date } - # qsearch('cust_credit', { 'custnum' => $self->custnum } ) - #; - #foreach (@cust_credit) { $total += $_->credited; } - #$total, @cust_credit; +sub cust_suspend_if_balance_over { + my( $self, $amount ) = ( shift, shift ); + my $cust_main = $self->cust_main; + if ( $cust_main->total_owed_date($self->_date) < $amount ) { + return (); + } else { + $cust_main->suspend(@_); + } } -=item cust_pay +=item cancel -Depreciated. See the cust_bill_pay method. - -#Returns all payments (see L) for this invoice. +Cancel the packages on this invoice. Largely similar to the cust_main version, but does not bother yet with banned payment options =cut -sub cust_pay { - use Carp; - croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay"; - #my $self = shift; - #sort { $a->_date <=> $b->_date } - # qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) - #; +sub cancel { + my( $self, %opt ) = @_; + + warn "$me cancel called on cust_bill ". $self->invnum . " with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" + if $DEBUG; + + return ( 'Access denied' ) + unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); + + my @pkgs = $self->cust_pkg; + + if ( !$opt{nobill} && $self->conf->exists('bill_usage_on_cancel') ) { + $opt{nobill} = 1; + my $error = $self->cust_main->bill( pkg_list => [ @pkgs ], cancel => 1 ); + warn "Error billing during cancel, custnum ". $self->custnum. ": $error" + if $error; + } + + grep { $_ } + map { $_->cancel(%opt) } + grep { ! $_->getfield('cancel') } + @pkgs; } =item cust_bill_pay @@ -310,23 +692,136 @@ Returns all payment applications (see L) for this invoice. sub cust_bill_pay { my $self = shift; + map { $_ } #return $self->num_cust_bill_pay unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } ); } =item cust_credited +=item cust_credit_bill + Returns all applied credits (see L) for this invoice. =cut sub cust_credited { my $self = shift; + map { $_ } #return $self->num_cust_credit_bill unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } ) ; } +sub cust_credit_bill { + shift->cust_credited(@_); +} + +#=item cust_bill_pay_pkgnum PKGNUM +# +#Returns all payment applications (see L) for this invoice +#with matching pkgnum. +# +#=cut +# +#sub cust_bill_pay_pkgnum { +# my( $self, $pkgnum ) = @_; +# map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray; +# sort { $a->_date <=> $b->_date } +# qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum, +# 'pkgnum' => $pkgnum, +# } +# ); +#} + +=item cust_bill_pay_pkg PKGNUM + +Returns all payment applications (see L) for this invoice +applied against the matching pkgnum. + +=cut + +sub cust_bill_pay_pkg { + my( $self, $pkgnum ) = @_; + + qsearch({ + 'select' => 'cust_bill_pay_pkg.*', + 'table' => 'cust_bill_pay_pkg', + 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '. + ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ', + 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum. + " AND cust_bill_pkg.pkgnum = $pkgnum", + }); + +} + +#=item cust_credited_pkgnum PKGNUM +# +#=item cust_credit_bill_pkgnum PKGNUM +# +#Returns all applied credits (see L) for this invoice +#with matching pkgnum. +# +#=cut +# +#sub cust_credited_pkgnum { +# my( $self, $pkgnum ) = @_; +# map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray; +# sort { $a->_date <=> $b->_date } +# qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum, +# 'pkgnum' => $pkgnum, +# } +# ); +#} +# +#sub cust_credit_bill_pkgnum { +# shift->cust_credited_pkgnum(@_); +#} + +=item cust_credit_bill_pkg PKGNUM + +Returns all credit applications (see L) for this invoice +applied against the matching pkgnum. + +=cut + +sub cust_credit_bill_pkg { + my( $self, $pkgnum ) = @_; + + qsearch({ + 'select' => 'cust_credit_bill_pkg.*', + 'table' => 'cust_credit_bill_pkg', + 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '. + ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ', + 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum. + " AND cust_bill_pkg.pkgnum = $pkgnum", + }); + +} + +=item cust_bill_batch + +Returns all invoice batch records (L) for this invoice. + +=cut + +sub cust_bill_batch { + my $self = shift; + qsearch('cust_bill_batch', { 'invnum' => $self->invnum }); +} + +=item discount_plans + +Returns all discount plans (L) for this invoice, as a +hash keyed by term length. + +=cut + +sub discount_plans { + my $self = shift; + FS::discount_plan->all($self); +} + =item tax Returns the tax amount (see L) for this invoice. @@ -360,605 +855,2293 @@ sub owed { $balance; } -=item send - -Sends this invoice to the destinations configured for this customer: send -emails or print. See L. - -=cut +sub owed_pkgnum { + my( $self, $pkgnum ) = @_; -sub send { - my($self,$template) = @_; - - #my @print_text = $cust_bill->print_text; #( date ) - my @invoicing_list = $self->cust_main->invoicing_list; - if ( grep { $_ ne 'POST' } @invoicing_list ) { #email invoice - #false laziness w/FS::cust_pay::delete & fs_signup_server && ::realtime_card - #$ENV{SMTPHOSTS} = $smtpmachine; - $ENV{MAILADDRESS} = $invoice_from; - my $header = new Mail::Header ( [ - "From: $invoice_from", - "To: ". join(', ', grep { $_ ne 'POST' } @invoicing_list ), - "Sender: $invoice_from", - "Reply-To: $invoice_from", - "Date: ". time2str("%a, %d %b %Y %X %z", time), - "Subject: Invoice", - ] ); - my $message = new Mail::Internet ( - 'Header' => $header, - 'Body' => [ $self->print_text('', $template) ], #( date) - ); - $!=0; - $message->smtpsend( Host => $smtpmachine ) - or $message->smtpsend( Host => $smtpmachine, Debug => 1 ) - or return "(customer # ". $self->custnum. ") can't send invoice email". - " to ". join(', ', grep { $_ ne 'POST' } @invoicing_list ). - " via server $smtpmachine with SMTP: $!"; - - #} elsif ( grep { $_ eq 'POST' } @invoicing_list ) { - } elsif ( ! @invoicing_list || grep { $_ eq 'POST' } @invoicing_list ) { - open(LPR, "|$lpr") - or return "Can't open pipe to $lpr: $!"; - print LPR $self->print_text; #( date ) - close LPR - or return $! ? "Error closing $lpr: $!" - : "Exit status $? from $lpr"; - } + #my $balance = $self->charged; + my $balance = 0; + $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); - ''; + $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum); + $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum); + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; } -=item comp +=item hide -Pays this invoice with a compliemntary payment. If there is an error, -returns the error, otherwise returns false. +Returns true if this invoice should be hidden. See the +selfservice-hide_invoices-taxclass configuraiton setting. =cut -sub comp { +sub hide { my $self = shift; - my $cust_pay = new FS::cust_pay ( { - 'invnum' => $self->invnum, - 'paid' => $self->owed, - '_date' => '', - 'payby' => 'COMP', - 'payinfo' => $self->cust_main->payinfo, - 'paybatch' => '', - } ); - $cust_pay->insert; + my $conf = $self->conf; + my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass') + or return ''; + my @cust_bill_pkg = $self->cust_bill_pkg; + my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg; + ! grep { $_->taxclass ne $hide_taxclass } @part_pkg; } -=item realtime_card - -Attempts to pay this invoice with a Business::OnlinePayment realtime gateway. -See http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment -for supproted processors. - -=cut +=item apply_payments_and_credits [ OPTION => VALUE ... ] -sub realtime_card { - my $self = shift; - my $cust_main = $self->cust_main; - my $amount = $self->owed; +Applies unapplied payments and credits to this invoice. +Payments with the no_auto_apply flag set will not be applied. - unless ( $processor =~ /^Business::OnlinePayment::(.*)$/ ) { - return "Real-time card processing not enabled (processor $processor)"; - } - my $bop_processor = $1; #hmm? +A hash of optional arguments may be passed. Currently "manual" is supported. +If true, a payment receipt is sent instead of a statement when +'payment_receipt_email' configuration option is set. - my $address = $cust_main->address1; - $address .= ", ". $cust_main->address2 if $cust_main->address2; +If there is an error, returns the error, otherwise returns false. - #fix exp. date - #$cust_main->paydate =~ /^(\d+)\/\d*(\d{2})$/; - $cust_main->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; - my $exp = "$2/$1"; +=cut - my($payname, $payfirst, $paylast); - if ( $cust_main->payname ) { - $payname = $cust_main->payname; - $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ - or do { - #$dbh->rollback if $oldAutoCommit; - return "Illegal payname $payname"; - }; - ($payfirst, $paylast) = ($1, $2); - } else { - $payfirst = $cust_main->getfield('first'); - $paylast = $cust_main->getfield('last'); - $payname = "$payfirst $paylast"; +sub apply_payments_and_credits { + my( $self, %options ) = @_; + my $conf = $self->conf; + + 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 + + my @payments = grep { $_->unapplied > 0 } + grep { !$_->no_auto_apply } + $self->cust_main->cust_pay; + my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; + + if ( $conf->exists('pkg-balances') ) { + # limit @payments & @credits to those w/ a pkgnum grepped from $self + my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg; + @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments; + @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits; } - my @invoicing_list = grep { $_ ne 'POST' } $cust_main->invoicing_list; - if ( $conf->exists('emailinvoiceauto') - || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { - push @invoicing_list, $cust_main->default_invoicing_list; - } - my $email = $invoicing_list[0]; + while ( $self->owed > 0 and ( @payments || @credits ) ) { + + my $app = ''; + if ( @payments && @credits ) { + + #decide which goes first by weight of top (unapplied) line item + + my @open_lineitems = $self->open_cust_bill_pkg; + + my $max_pay_weight = + max( map { $_->part_pkg->pay_weight || 0 } + grep { $_ } + map { $_->cust_pkg } + @open_lineitems + ); + my $max_credit_weight = + max( map { $_->part_pkg->credit_weight || 0 } + grep { $_ } + map { $_->cust_pkg } + @open_lineitems + ); + + #if both are the same... payments first? it has to be something + if ( $max_pay_weight >= $max_credit_weight ) { + $app = 'pay'; + } else { + $app = 'credit'; + } + + } elsif ( @payments ) { + $app = 'pay'; + } elsif ( @credits ) { + $app = 'credit'; + } else { + die "guru meditation #12 and 35"; + } - my( $action1, $action2 ) = split(/\s*\,\s*/, $bop_action ); + my $unapp_amount; + if ( $app eq 'pay' ) { - my $description = 'Internet Services'; - if ( $conf->exists('business-onlinepayment-description') ) { - my $dtempl = $conf->config('business-onlinepayment-description'); + my $payment = shift @payments; + $unapp_amount = $payment->unapplied; + $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum }; + $app->pkgnum( $payment->pkgnum ) + if $conf->exists('pkg-balances') && $payment->pkgnum; - my $agent_obj = $cust_main->agent - or die "can't retreive agent for $cust_main (agentnum ". - $cust_main->agentnum. ")"; - my $agent = $agent_obj->agent; - my $pkgs = join(', ', - map { $_->cust_pkg->part_pkg->pkg } - grep { $_->pkgnum } $self->cust_bill_pkg - ); - $description = eval qq("$dtempl"); + } elsif ( $app eq 'credit' ) { - } - - my $transaction = - new Business::OnlinePayment( $bop_processor, @bop_options ); - $transaction->content( - 'type' => 'CC', - 'login' => $bop_login, - 'password' => $bop_password, - 'action' => $action1, - 'description' => $description, - 'amount' => $amount, - 'invoice_number' => $self->invnum, - 'customer_id' => $self->custnum, - 'last_name' => $paylast, - 'first_name' => $payfirst, - 'name' => $payname, - 'address' => $address, - 'city' => $cust_main->city, - 'state' => $cust_main->state, - 'zip' => $cust_main->zip, - 'country' => $cust_main->country, - 'card_number' => $cust_main->payinfo, - 'expiration' => $exp, - 'referer' => 'http://cleanwhisker.420.am/', - 'email' => $email, - 'phone' => $cust_main->daytime || $cust_main->night, - ); - $transaction->submit(); - - if ( $transaction->is_success() && $action2 ) { - my $auth = $transaction->authorization; - my $ordernum = $transaction->order_number; - #warn "********* $auth ***********\n"; - #warn "********* $ordernum ***********\n"; - my $capture = - new Business::OnlinePayment( $bop_processor, @bop_options ); - - $capture->content( - action => $action2, - login => $bop_login, - password => $bop_password, - order_number => $ordernum, - amount => $amount, - authorization => $auth, - description => $description, - ); + my $credit = shift @credits; + $unapp_amount = $credit->credited; + $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum }; + $app->pkgnum( $credit->pkgnum ) + if $conf->exists('pkg-balances') && $credit->pkgnum; - $capture->submit(); + } else { + die "guru meditation #12 and 35"; + } - unless ( $capture->is_success ) { - my $e = "Authorization sucessful but capture failed, invnum #". - $self->invnum. ': '. $capture->result_code. - ": ". $capture->error_message; - warn $e; - return $e; + my $owed; + if ( $conf->exists('pkg-balances') && $app->pkgnum ) { + warn "owed_pkgnum ". $app->pkgnum; + $owed = $self->owed_pkgnum($app->pkgnum); + } else { + $owed = $self->owed; } + next unless $owed > 0; - } + warn "min ( $unapp_amount, $owed )\n" if $DEBUG; + $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) ); - if ( $transaction->is_success() ) { + $app->invnum( $self->invnum ); - my $cust_pay = new FS::cust_pay ( { - 'invnum' => $self->invnum, - 'paid' => $amount, - '_date' => '', - 'payby' => 'CARD', - 'payinfo' => $cust_main->payinfo, - 'paybatch' => "$processor:". $transaction->authorization, - } ); - my $error = $cust_pay->insert; + my $error = $app->insert(%options); if ( $error ) { - # gah, even with transactions. - my $e = 'WARNING: Card debited but database not updated - '. - 'error applying payment, invnum #' . $self->invnum. - " ($processor): $error"; - warn $e; - return $e; - } else { - return ''; + $dbh->rollback if $oldAutoCommit; + return "Error inserting ". $app->table. " record: $error"; } - #} elsif ( $options{'report_badcard'} ) { - } else { + die $error if $error; - my $perror = "$processor error, invnum #". $self->invnum. ': '. - $transaction->result_code. ": ". $transaction->error_message; - - if ( $conf->exists('emaildecline') - && grep { $_ ne 'POST' } $cust_main->invoicing_list - ) { - my @templ = $conf->config('declinetemplate'); - my $template = new Text::Template ( - TYPE => 'ARRAY', - SOURCE => [ map "$_\n", @templ ], - ) or return "($perror) can't create template: $Text::Template::ERROR"; - $template->compile() - or return "($perror) can't compile template: $Text::Template::ERROR"; - - my $error = $transaction->error_message; - - #false laziness w/FS::cust_pay::delete & fs_signup_server && ::send - $ENV{MAILADDRESS} = $invoice_from; - my $header = new Mail::Header ( [ - "From: $invoice_from", - "To: ". join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list ), - "Sender: $invoice_from", - "Reply-To: $invoice_from", - "Date: ". time2str("%a, %d %b %Y %X %z", time), - "Subject: Your credit card could not be processed", - ] ); - my $message = new Mail::Internet ( - 'Header' => $header, - 'Body' => [ $template->fill_in() ], - ); - $!=0; - $message->smtpsend( Host => $smtpmachine ) - or $message->smtpsend( Host => $smtpmachine, Debug => 1 ) - or return "($perror) (customer # ". $self->custnum. - ") can't send card decline email to ". - join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list ). - " via server $smtpmachine with SMTP: $!"; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error + +} + +=item send HASHREF + +Sends this invoice to the destinations configured for this customer: sends +email, prints and/or faxes. See L. + +Options can be passed as a hashref. Positional parameters are no longer +allowed. + +I