X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=07c33204e84181f42210a0f939894998e6ffe166;hb=dc89d3d583bff237f56dac6dcda57412ba0584a8;hp=66d98c25755a26ffe97946c265ccb50274ab4834;hpb=4adeee8e1912405d691b7b24f016760fde151adf;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 66d98c257..07c33204e 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,8 +1,10 @@ package FS::cust_bill; -use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::Record ); +use base qw( FS::cust_bill::Search FS::Template_Mixin + FS::cust_main_Mixin FS::Record + ); use strict; -use vars qw( $DEBUG $me $date_format ); +use vars qw( $DEBUG $me ); # but NOT $conf use Fcntl qw(:flock); #for spool_csv use Cwd; @@ -15,7 +17,6 @@ use GD::Barcode; use FS::UID qw( datasrc ); use FS::Misc qw( send_email send_fax do_print ); use FS::Record qw( qsearch qsearchs dbh ); -use FS::cust_main; use FS::cust_statement; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; @@ -25,12 +26,10 @@ use FS::cust_pay; use FS::cust_pkg; use FS::cust_credit_bill; use FS::pay_batch; -use FS::cust_pay_batch; use FS::cust_bill_event; use FS::cust_event; use FS::part_pkg; use FS::cust_bill_pay; -use FS::cust_bill_pay_batch; use FS::part_bill_event; use FS::payby; use FS::bill_batch; @@ -44,12 +43,6 @@ use FS::L10N; $DEBUG = 0; $me = '[FS::cust_bill]'; -#ask FS::UID to run this stuff for us later -FS::UID->install_callback( sub { - my $conf = new FS::Conf; #global - $date_format = $conf->config('date_format') || '%x'; #/YY -} ); - =head1 NAME FS::cust_bill - Object methods for cust_bill records @@ -106,23 +99,19 @@ L and L for conversion functions. =back -Customer info at invoice generation time +Deprecated fields =over 4 -=item billing_balance - the customer's balance at the time the invoice was -generated (not including charges on this invoice) +=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 billing_balance of this customer's previous -invoice plus the charges on that invoice +=item previous_balance - the customer's balance immediately after generating +the invoice before this one. DEPRECATED. -=back - -Deprecated - -=over 4 - -=item printed - deprecated +=item printed - formerly used to track the number of times an invoice had +been printed; no longer used. =back @@ -138,6 +127,8 @@ Specific use cases =item promised_date - customer promised payment date, for collection +=item pending - invoice is still being generated, empty or 'Y' + =back =head1 METHODS @@ -161,7 +152,7 @@ sub notice_name { $self->conf->config('notice_name') || 'Invoice' } -sub cust_linked { $_[0]->cust_main_custnum; } +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. @@ -347,6 +338,7 @@ sub replace_check { #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 + || $old->pending eq 'Y' || $old->charged == 0 || $new->{'Hash'}{'cc_surcharge_replace_hack'}; @@ -401,6 +393,7 @@ sub check { || $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; @@ -420,8 +413,8 @@ cust_bill-default_agent_invid is set and it has a value, invnum otherwise. sub display_invnum { my $self = shift; - my $conf = $self->conf; - if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){ + if ( $self->agent_invid + && FS::Conf->new->exists('cust_bill-default_agent_invid') ) { return $self->agent_invid; } else { return $self->invnum; @@ -492,7 +485,9 @@ sub cust_bill_pkg { qsearch( { 'table' => 'cust_bill_pkg', 'hashref' => { 'invnum' => $self->invnum }, - 'order_by' => 'ORDER BY billpkgnum', + 'order_by' => 'ORDER BY billpkgnum', #important? otherwise we could use + # the AUTLOADED FK search. or should + # that default to ORDER by the pkey? } ); } @@ -634,11 +629,21 @@ sub num_cust_event { 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_suspend_if_balance_over AMOUNT @@ -660,55 +665,35 @@ sub cust_suspend_if_balance_over { } } -=item cust_credit - -Depreciated. See the cust_credited method. +=item cancel - #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). +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_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; -} - -=item cust_pay - -Depreciated. See the cust_bill_pay method. +sub cancel { + my( $self, %opt ) = @_; -#Returns all payments (see L) for this invoice. + warn "$me cancel called on cust_bill ". $self->invnum . " with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" + if $DEBUG; -=cut + return ( 'Access denied' ) + unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); -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 } ) - #; -} + my @pkgs = $self->cust_pkg; -sub cust_pay_batch { - my $self = shift; - qsearch('cust_pay_batch', { 'invnum' => $self->invnum } ); -} + 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; + } -sub cust_bill_pay_batch { - my $self = shift; - qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } ); + grep { $_ } + map { $_->cancel(%opt) } + grep { ! $_->getfield('cancel') } + @pkgs; } =item cust_bill_pay @@ -1084,6 +1069,8 @@ sub generate_email { my %return = ( 'from' => $args{'from'}, 'subject' => ($args{'subject'} || $self->email_subject), + 'custnum' => $self->custnum, + 'msgtype' => 'invoice', ); $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email'); @@ -1136,6 +1123,7 @@ sub generate_email { $alternative->attach( 'Type' => 'text/plain', 'Encoding' => 'quoted-printable', + 'Charset' => 'UTF-8', #'Encoding' => '7bit', 'Data' => $data, 'Disposition' => 'inline', @@ -1414,7 +1402,7 @@ sub email { my $self = shift; return if $self->hide; my $conf = $self->conf; - my $opt = shift; + my $opt = shift || {}; if ($opt and !ref($opt)) { die "FS::cust_bill::email called with positional parameters"; } @@ -1489,7 +1477,7 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub lpr_data { my $self = shift; my $conf = $self->conf; - my $opt = shift; + my $opt = shift || {}; if ($opt and !ref($opt)) { # nobody does this anyway die "FS::cust_bill::lpr_data called with positional parameters"; @@ -1515,7 +1503,7 @@ sub print { my $self = shift; return if $self->hide; my $conf = $self->conf; - my $opt = shift; + my $opt = shift || {}; if ($opt and !ref($opt)) { die "FS::cust_bill::print called with positional parameters"; } @@ -1550,7 +1538,7 @@ sub fax_invoice { my $self = shift; return if $self->hide; my $conf = $self->conf; - my $opt = shift; + my $opt = shift || {}; if ($opt and !ref($opt)) { die "FS::cust_bill::fax_invoice called with positional parameters"; } @@ -1573,6 +1561,8 @@ sub fax_invoice { Place this invoice into the open batch (see C). If there isn't an open batch, one will be created. +HASHREF may contain any options to be passed to C. + =cut sub batch_invoice { @@ -1677,6 +1667,7 @@ sub send_csv { my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; mkdir $spooldir, 0700 unless -d $spooldir; + # don't localize dates here, they're a defined format my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time); my $file = "$spooldir/$tracctnum.csv"; @@ -1970,25 +1961,26 @@ sub print_csv { my $time = $opt{'time'} || time; + my $tracctnum = ''; #leaking out from billco-specific sections :/ if ( $format eq 'billco' ) { my $account_num = $self->conf->config('billco-account_num', $cust_main->agentnum); - my $tracctnum = $account_num eq 'display_custnum' - ? $cust_main->display_custnum - : $opt{'tracctnum'}; + $tracctnum = $account_num eq 'display_custnum' + ? $cust_main->display_custnum + : $opt{'tracctnum'}; my $taxtotal = 0; $taxtotal += $_->{'amount'} foreach $self->_items_tax; - my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format? + my $duedate = $self->due_date2str('%m/%d/%Y'); # hardcoded, NOT date_format my( $previous_balance, @unused ) = $self->previous; #previous balance my $pmt_cr_applied = 0; $pmt_cr_applied += $_->{'amount'} - foreach ( $self->_items_payments, $self->_items_credits ) ; + foreach ( $self->_items_payments(%opt), $self->_items_credits(%opt) ) ; my $totaldue = sprintf('%.2f', $self->owed + $previous_balance); @@ -2232,7 +2224,7 @@ sub print_csv { $csv->combine( '', # 1 | N/A-Leave Empty CHAR 2 '', # 2 | N/A-Leave Empty CHAR 15 - $opt{'tracctnum'}, # 3 | Account Number CHAR 15 + $tracctnum, # 3 | Account Number CHAR 15 $self->invnum, # 4 | Invoice Number CHAR 15 $lineseq++, # 5 | Line Sequence (sort order) NUM 6 $item->{'description'}, # 6 | Transaction Detail CHAR 100 @@ -2269,7 +2261,7 @@ sub print_csv { ? time2str("%x", $cust_bill_pkg->sdate) : '' ), ($cust_bill_pkg->edate - ?time2str("%x", $cust_bill_pkg->edate) + ? time2str("%x", $cust_bill_pkg->edate) : '' ), ); @@ -2460,13 +2452,20 @@ sub invoice_barcode { =item invnum_date_pretty Returns a string with the invoice number and date, for example: -"Invoice #54 (3/20/2008)" +"Invoice #54 (3/20/2008)". + +Intended for back-end context, with regard to translation and date formatting. =cut +#note: this uses _date_pretty_unlocalized because _date_pretty is too expensive +# for backend use (and also does the wrong thing, localizing for end customer +# instead of backoffice configured date format) sub invnum_date_pretty { my $self = shift; - $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')'; + #$self->mt('Invoice #'). + 'Invoice #'. #XXX should be translated ala web UI user (not invoice customer) + $self->invnum. ' ('. $self->_date_pretty_unlocalized. ')'; } #sub _items_extra_usage_sections { @@ -2974,6 +2973,49 @@ sub _items_svc_phone_sections { } +=sub _items_usage_class_summary OPTIONS + +Returns a list of detail items summarizing the usage charges on this +invoice. Each one will have 'amount', 'description' (the usage charge name), +and 'usage_classnum'. + +OPTIONS can include 'escape' (a function to escape the descriptions). + +=cut + +sub _items_usage_class_summary { + my $self = shift; + my %opt = @_; + + my $escape = $opt{escape} || sub { $_[0] }; + my $invnum = $self->invnum; + my @classes = qsearch({ + 'table' => 'usage_class', + 'select' => 'classnum, classname, SUM(amount) AS amount', + 'addl_from' => ' LEFT JOIN cust_bill_pkg_detail USING (classnum)' . + ' LEFT JOIN cust_bill_pkg USING (billpkgnum)', + 'extra_sql' => " WHERE cust_bill_pkg.invnum = $invnum". + ' GROUP BY classnum, classname, weight'. + ' HAVING (usage_class.disabled IS NULL OR SUM(amount) > 0)'. + ' ORDER BY weight ASC', + }); + my @l; + my $section = { + description => &{$escape}($self->mt('Usage Summary')), + no_subtotal => 1, + usage_section => 1, + }; + foreach my $class (@classes) { + push @l, { + 'description' => &{$escape}($class->classname), + 'amount' => sprintf('%.2f', $class->amount), + 'usage_classnum' => $class->classnum, + 'section' => $section, + }; + } + return @l; +} + sub _items_previous { my $self = shift; my $conf = $self->conf; @@ -2982,8 +3024,8 @@ sub _items_previous { my @b = (); foreach ( @pr_cust_bill ) { my $date = $conf->exists('invoice_show_prior_due_date') - ? 'due '. $_->due_date2str($date_format) - : time2str($date_format, $_->_date); + ? 'due '. $_->due_date2str('short') + : $self->time2str_local('short', $_->_date); push @b, { 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)", #'pkgpart' => 'N/A', @@ -3015,14 +3057,22 @@ sub _items_credits { #credits my @objects; if ( $self->conf->exists('previous_balance-payments_since') ) { - my $date = 0; - $date = $self->previous_bill->_date if $self->previous_bill; - @objects = qsearch('cust_credit', { - 'custnum' => $self->custnum, - '_date' => {op => '>=', value => $date}, + if ( $opt{'template'} eq 'statement' ) { + # then the current bill is a "statement" (i.e. an invoice sent as + # a payment receipt) + # and in that case we want to see payments on or after THIS invoice + @objects = qsearch('cust_credit', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $self->_date}, }); - # hard to do this in the qsearch... - @objects = grep { $_->_date < $self->_date } @objects; + } else { + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_credit', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $date}, + }); + } } else { @objects = $self->cust_credited; } @@ -3039,7 +3089,7 @@ sub _items_credits { # " (". time2str("%x",$_->cust_credit->_date) .")". # $reason, 'description' => $self->mt('Credit applied').' '. - time2str($date_format,$obj->_date). $reason, + $self->time2str_local('short', $obj->_date). $reason, 'amount' => sprintf("%.2f",$obj->amount), }; } @@ -3050,18 +3100,32 @@ sub _items_credits { sub _items_payments { my $self = shift; + my %opt = @_; my @b; my $detailed = $self->conf->exists('invoice_payment_details'); my @objects; if ( $self->conf->exists('previous_balance-payments_since') ) { - my $date = 0; - $date = $self->previous_bill->_date if $self->previous_bill; - @objects = qsearch('cust_pay', { + # then show payments dated on/after the previous bill... + if ( $opt{'template'} eq 'statement' ) { + # then the current bill is a "statement" (i.e. an invoice sent as + # a payment receipt) + # and in that case we want to see payments on or after THIS invoice + @objects = qsearch('cust_pay', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $self->_date}, + }); + } else { + # the normal case: payments on or after the previous invoice + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_pay', { 'custnum' => $self->custnum, '_date' => {op => '>=', value => $date}, }); - @objects = grep { $_->_date < $self->_date } @objects; + # and before the current bill... + @objects = grep { $_->_date < $self->_date } @objects; + } } else { @objects = $self->cust_bill_pay; } @@ -3069,8 +3133,9 @@ sub _items_payments { foreach my $obj (@objects) { my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay; my $desc = $self->mt('Payment received').' '. - time2str($date_format, $cust_pay->_date ); - $desc .= $self->mt(' via ' . $cust_pay->payby_payinfo_pretty) + $self->time2str_local('short', $cust_pay->_date ); + $desc .= $self->mt(' via ') . + $cust_pay->payby_payinfo_pretty( $self->cust_main->locale ) if $detailed; push @b, { @@ -3162,14 +3227,12 @@ sub process_respool { process_re_X('spool', @_); } -use Storable qw(thaw); use Data::Dumper; -use MIME::Base64; sub process_re_X { my( $method, $job ) = ( shift, shift ); warn "$me process_re_X $method for job $job\n" if $DEBUG; - my $param = thaw(decode_base64(shift)); + my $param = shift; warn Dumper($param) if $DEBUG; re_X( @@ -3230,6 +3293,14 @@ sub re_X { } +sub API_getinfo { + my $self = shift; + +{ ( map { $_=>$self->$_ } $self->fields ), + 'owed' => $self->owed, + #XXX last payment applied date + }; +} + =back =head1 CLASS METHODS @@ -3300,6 +3371,13 @@ Currently only supported on PostgreSQL. =cut sub due_date_sql { + die "don't use: doesn't account for agent-specific invoice_default_terms"; + + #we're passed a $conf but not a specific customer (that's in the query), so + # to make this work we'd need an agentnum-aware "condition_sql_conf" like + # "condition_sql_option" that retreives a conf value with SQL in an agent- + # aware fashion + my $conf = new FS::Conf; 'COALESCE( SUBSTRING( @@ -3312,195 +3390,6 @@ sub due_date_sql { ) * 86400 + cust_bill._date' } -=item search_sql_where HASHREF - -Class method which returns an SQL WHERE fragment to search for parameters -specified in HASHREF. Valid parameters are - -=over 4 - -=item _date - -List reference of start date, end date, as UNIX timestamps. - -=item invnum_min - -=item invnum_max - -=item agentnum - -=item charged - -List reference of charged limits (exclusive). - -=item owed - -List reference of charged limits (exclusive). - -=item open - -flag, return open invoices only - -=item net - -flag, return net invoices only - -=item days - -=item newest_percust - -=back - -Note: validates all passed-in data; i.e. safe to use with unchecked CGI params. - -=cut - -sub search_sql_where { - my($class, $param) = @_; - if ( $DEBUG ) { - warn "$me search_sql_where called with params: \n". - join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n"; - } - - my @search = (); - - #agentnum - if ( $param->{'agentnum'} =~ /^(\d+)$/ ) { - push @search, "cust_main.agentnum = $1"; - } - - #refnum - if ( $param->{'refnum'} =~ /^(\d+)$/ ) { - push @search, "cust_main.refnum = $1"; - } - - #custnum - if ( $param->{'custnum'} =~ /^(\d+)$/ ) { - push @search, "cust_bill.custnum = $1"; - } - - #customer classnum (false laziness w/ cust_main/Search.pm) - if ( $param->{'cust_classnum'} ) { - - my @classnum = ref( $param->{'cust_classnum'} ) - ? @{ $param->{'cust_classnum'} } - : ( $param->{'cust_classnum'} ); - - @classnum = grep /^(\d*)$/, @classnum; - - if ( @classnum ) { - push @search, '( '. join(' OR ', map { - $_ ? "cust_main.classnum = $_" - : "cust_main.classnum IS NULL" - } - @classnum - ). - ' )'; - } - - } - - #_date - if ( $param->{_date} ) { - my($beginning, $ending) = @{$param->{_date}}; - - push @search, "cust_bill._date >= $beginning", - "cust_bill._date < $ending"; - } - - #invnum - if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) { - push @search, "cust_bill.invnum >= $1"; - } - if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) { - push @search, "cust_bill.invnum <= $1"; - } - - #charged - if ( $param->{charged} ) { - my @charged = ref($param->{charged}) - ? @{ $param->{charged} } - : ($param->{charged}); - - push @search, map { s/^charged/cust_bill.charged/; $_; } - @charged; - } - - my $owed_sql = FS::cust_bill->owed_sql; - - #owed - if ( $param->{owed} ) { - my @owed = ref($param->{owed}) - ? @{ $param->{owed} } - : ($param->{owed}); - push @search, map { s/^owed/$owed_sql/; $_; } - @owed; - } - - #open/net flags - push @search, "0 != $owed_sql" - if $param->{'open'}; - push @search, '0 != '. FS::cust_bill->net_sql - if $param->{'net'}; - - #days - push @search, "cust_bill._date < ". (time-86400*$param->{'days'}) - if $param->{'days'}; - - #newest_percust - if ( $param->{'newest_percust'} ) { - - #$distinct = 'DISTINCT ON ( cust_bill.custnum )'; - #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC'; - - my @newest_where = map { my $x = $_; - $x =~ s/\bcust_bill\./newest_cust_bill./g; - $x; - } - grep ! /^cust_main./, @search; - my $newest_where = scalar(@newest_where) - ? ' AND '. join(' AND ', @newest_where) - : ''; - - - push @search, "cust_bill._date = ( - SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill - WHERE newest_cust_bill.custnum = cust_bill.custnum - $newest_where - )"; - - } - - #promised_date - also has an option to accept nulls - if ( $param->{promised_date} ) { - my($beginning, $ending, $null) = @{$param->{promised_date}}; - - push @search, "(( cust_bill.promised_date >= $beginning AND ". - "cust_bill.promised_date < $ending )" . - ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')'); - } - - #agent virtualization - my $curuser = $FS::CurrentUser::CurrentUser; - if ( $curuser->username eq 'fs_queue' - && $param->{'CurrentUser'} =~ /^(\w+)$/ ) { - my $username = $1; - my $newuser = qsearchs('access_user', { - 'username' => $username, - 'disabled' => '', - } ); - if ( $newuser ) { - $curuser = $newuser; - } else { - warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n"; - } - } - push @search, $curuser->agentnums_sql; - - join(' AND ', @search ); - -} - =back =head1 BUGS