X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=47f71c45890751c7eca158fa28dba437623d322c;hp=0b8cb025418244de8020499990e5ec13cdd5aa67;hb=a5bfed744069d69a1fe07eca1a64a2b22692cc22;hpb=82fca8a493a6490e385b40ebea690b29bf35bdbd diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 0b8cb0254..47f71c458 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -6,16 +6,18 @@ use base qw( FS::cust_bill::Search FS::Template_Mixin use strict; 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 DateTime; 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_email send_fax do_print ); +use FS::Misc qw( send_fax do_print ); use FS::Record qw( qsearch qsearchs dbh ); use FS::cust_statement; use FS::cust_bill_pkg; @@ -26,11 +28,9 @@ use FS::cust_pay; use FS::cust_pkg; use FS::cust_credit_bill; use FS::pay_batch; -use FS::cust_bill_event; use FS::cust_event; use FS::part_pkg; use FS::cust_bill_pay; -use FS::part_bill_event; use FS::payby; use FS::bill_batch; use FS::cust_bill_batch; @@ -38,6 +38,8 @@ use FS::cust_bill_pay_pkg; use FS::cust_credit_bill_pkg; use FS::discount_plan; use FS::cust_bill_void; +use FS::reason; +use FS::reason_type; use FS::L10N; $DEBUG = 0; @@ -104,13 +106,13 @@ Deprecated fields =over 4 =item billing_balance - the customer's balance immediately before generating -this invoice. DEPRECATED. Use the L method +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 +=item printed - formerly used to track the number of times an invoice had been printed; no longer used. =back @@ -144,6 +146,7 @@ Invoices are normally created by calling the bill method of a customer object =cut sub table { 'cust_bill'; } +sub template_conf { 'invoice_'; } # should be the ONLY occurrence of "Invoice" in invoice rendering code. # (except email_subject and invnum_date_pretty) @@ -152,7 +155,7 @@ sub notice_name { $self->conf->config('notice_name') || 'Invoice' } -sub cust_linked { $_[0]->cust_main_custnum || $_[0]->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. @@ -203,7 +206,7 @@ sub insert { } -=item void +=item void [ REASON [ , REPROCESS_CDRS ] ] 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 @@ -214,6 +217,15 @@ FS::cust_bill_pkg_void). sub void { my $self = shift; my $reason = scalar(@_) ? shift : ''; + my $reprocess_cdrs = scalar(@_) ? shift : ''; + + unless (ref($reason) || !$reason) { + $reason = FS::reason->new_or_existing( + 'class' => 'I', + 'type' => 'Invoice void', + 'reason' => $reason + ); + } local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -229,7 +241,7 @@ sub void { my $cust_bill_void = new FS::cust_bill_void ( { map { $_ => $self->get($_) } $self->fields } ); - $cust_bill_void->reason($reason); + $cust_bill_void->reasonnum($reason->reasonnum) if $reason; my $error = $cust_bill_void->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -237,14 +249,14 @@ sub void { } foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { - my $error = $cust_bill_pkg->void($reason); + my $error = $cust_bill_pkg->void($reason, $reprocess_cdrs); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } } - $error = $self->delete; + $error = $self->_delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -256,20 +268,22 @@ sub void { } -=item delete - -This method now works but you probably shouldn't use it. Instead, apply a -credit against the invoice, or use the new void method. - -Using this method to delete invoices outright is really, really bad. There -would be no record you ever posted this invoice, and there are no check to -make sure charged = 0 or that there are no associated cust_bill_pkg records. - -Really, don't use it. - -=cut +# 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 { +sub _delete { my $self = shift; return "Can't delete closed invoice" if $self->closed =~ /^Y/i; @@ -285,15 +299,14 @@ sub delete { my $dbh = dbh; foreach my $table (qw( - cust_bill_event - cust_event cust_credit_bill - cust_bill_pay - cust_pay_batch 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; @@ -440,25 +453,50 @@ sub previous_bill { $self->get('previous_bill'); } +=item following_bill + +Returns the customer's invoice that follows this one + +=cut + +sub following_bill { + my $self = shift; + if (!$self->get('following_bill')) { + $self->set('following_bill', qsearchs({ + table => 'cust_bill', + hashref => { + custnum => $self->custnum, + invnum => { op => '>', value => $self->invnum }, + }, + order_by => 'ORDER BY invnum ASC LIMIT 1', + })); + } + $self->get('following_bill'); +} + =item previous -Returns a list consisting of the total previous balance for this customer, +Returns a list consisting of the total previous balance for this customer, followed by the previous outstanding invoices (as FS::cust_bill objects also). =cut sub previous { my $self = shift; - 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; } - $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 @@ -483,7 +521,13 @@ Returns the line items (see L) for this invoice. sub cust_bill_pkg { my $self = shift; qsearch( - { 'table' => 'cust_bill_pkg', + { + 'select' => 'cust_bill_pkg.*, pkg_category.categoryname', + 'table' => 'cust_bill_pkg', + 'addl_from' => ' LEFT JOIN cust_pkg USING ( pkgnum ) '. + ' LEFT JOIN part_pkg USING ( pkgpart ) '. + ' LEFT JOIN pkg_class USING ( classnum ) '. + ' LEFT JOIN pkg_category USING ( categorynum ) ', 'hashref' => { 'invnum' => $self->invnum }, 'order_by' => 'ORDER BY billpkgnum', #important? otherwise we could use # the AUTLOADED FK search. or should @@ -565,32 +609,6 @@ sub open_cust_bill_pkg { @open; } -=item cust_bill_event - -Returns the completed invoice events (deprecated, old-style events - see L) for this invoice. - -=cut - -sub cust_bill_event { - my $self = shift; - qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } ); -} - -=item num_cust_bill_event - -Returns the number of completed invoice events (deprecated, old-style events - see L) for this invoice. - -=cut - -sub num_cust_bill_event { - my $self = shift; - my $sql = - "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?"; - 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_event Returns the new-style customer billing events (see L) for this invoice. @@ -620,7 +638,7 @@ sub num_cust_event { 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"; + 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]; } @@ -640,8 +658,8 @@ Returns a list: an empty list on success or a list of errors. sub suspend { my $self = shift; - grep { $_->suspend(@_) } - grep {! $_->getfield('cancel') } + grep { $_->suspend(@_) } + grep {! $_->getfield('cancel') } $self->cust_pkg; } @@ -678,21 +696,22 @@ sub cancel { join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" if $DEBUG; - return ( 'access denied' ) + return ( 'Access denied' ) unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); my @pkgs = $self->cust_pkg; - if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) { + 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; + grep { $_ } + map { $_->cancel(%opt) } + grep { ! $_->getfield('cancel') } + @pkgs; } =item cust_bill_pay @@ -823,7 +842,7 @@ sub cust_bill_batch { =item discount_plans -Returns all discount plans (L) for this invoice, as a +Returns all discount plans (L) for this invoice, as a hash keyed by term length. =cut @@ -866,6 +885,35 @@ sub owed { $balance; } +=item owed_on_invoice + +Returns the amount to be displayed as the "Balance Due" on this +invoice. Amount returned depends on conf flags for invoicing + +See L for the true amount currently owed + +=cut + +sub owed_on_invoice { + my $self = shift; + + #return $self->owed() + # unless $self->conf->exists('previous_balance-payments_since') + + # Add charges from this invoice + my $owed = $self->charged(); + + # Add carried balances from previous invoices + # If previous items aren't to be displayed on the invoice, + # _items_previous() is aware of this and responds appropriately. + $owed += $_->{amount} for $self->_items_previous(); + + # Subtract payments and credits displayed on this invoice + $owed -= $_->{amount} for $self->_items_payments(), $self->_items_credits(); + + return $owed; +} + sub owed_pkgnum { my( $self, $pkgnum ) = @_; @@ -901,6 +949,7 @@ sub hide { =item apply_payments_and_credits [ OPTION => VALUE ... ] Applies unapplied payments and credits to this invoice. +Payments with the no_auto_apply flag set will not be applied. A hash of optional arguments may be passed. Currently "manual" is supported. If true, a payment receipt is sent instead of a statement when @@ -927,7 +976,9 @@ sub apply_payments_and_credits { $self->select_for_update; #mutex - my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay; + 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') ) { @@ -954,7 +1005,7 @@ sub apply_payments_and_credits { ); my $max_credit_weight = max( map { $_->part_pkg->credit_weight || 0 } - grep { $_ } + grep { $_ } map { $_->cust_pkg } @open_lineitems ); @@ -965,7 +1016,7 @@ sub apply_payments_and_credits { } else { $app = 'credit'; } - + } elsif ( @payments ) { $app = 'pay'; } elsif ( @credits ) { @@ -1023,301 +1074,6 @@ sub apply_payments_and_credits { } -=item generate_email OPTION => VALUE ... - -Options: - -=over 4 - -=item from - -sender address, required - -=item template - -alternate template name, optional - -=item print_text - -text attachment arrayref, optional - -=item subject - -email subject, optional - -=item notice_name - -notice name instead of "Invoice", optional - -=back - -Returns an argument list to be passed to L. - -=cut - -use MIME::Entity; - -sub generate_email { - - my $self = shift; - my %args = @_; - my $conf = $self->conf; - - my $me = '[FS::cust_bill::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'); - - my $cust_main = $self->cust_main; - - if (ref($args{'to'}) eq 'ARRAY') { - $return{'to'} = $args{'to'}; - } else { - $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ } - $cust_main->invoicing_list - ]; - } - - if ( $conf->exists('invoice_html') ) { - - warn "$me creating HTML/text multipart message" - if $DEBUG; - - $return{'nobody'} = 1; - - my $alternative = build MIME::Entity - 'Type' => 'multipart/alternative', - #'Encoding' => '7bit', - 'Disposition' => 'inline' - ; - - my $data; - if ( $conf->exists('invoice_email_pdf') - and scalar($conf->config('invoice_email_pdf_note')) ) { - - warn "$me using 'invoice_email_pdf_note' in multipart message" - if $DEBUG; - $data = [ map { $_ . "\n" } - $conf->config('invoice_email_pdf_note') - ]; - - } else { - - warn "$me not using 'invoice_email_pdf_note' in multipart message" - if $DEBUG; - if ( ref($args{'print_text'}) eq 'ARRAY' ) { - $data = $args{'print_text'}; - } else { - $data = [ $self->print_text(\%args) ]; - } - - } - - $alternative->attach( - 'Type' => 'text/plain', - 'Encoding' => 'quoted-printable', - 'Charset' => 'UTF-8', - #'Encoding' => '7bit', - 'Data' => $data, - 'Disposition' => 'inline', - ); - - - my $htmldata; - my $image = ''; - my $barcode = ''; - if ( $conf->exists('invoice_email_pdf') - and scalar($conf->config('invoice_email_pdf_note')) ) { - - $htmldata = join('
', $conf->config('invoice_email_pdf_note') ); - - } else { - - $args{'from'} =~ /\@([\w\.\-]+)/; - my $from = $1 || 'example.com'; - my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; - - my $logo; - my $agentnum = $cust_main->agentnum; - if ( defined($args{'template'}) && length($args{'template'}) - && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum ) - ) - { - $logo = 'logo_'. $args{'template'}. '.png'; - } else { - $logo = "logo.png"; - } - my $image_data = $conf->config_binary( $logo, $agentnum); - - $image = build MIME::Entity - 'Type' => 'image/png', - 'Encoding' => 'base64', - 'Data' => $image_data, - 'Filename' => 'logo.png', - 'Content-ID' => "<$content_id>", - ; - - if ($conf->exists('invoice-barcode')) { - my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from"; - $barcode = build MIME::Entity - 'Type' => 'image/png', - 'Encoding' => 'base64', - 'Data' => $self->invoice_barcode(0), - 'Filename' => 'barcode.png', - 'Content-ID' => "<$barcode_content_id>", - ; - $args{'barcode_cid'} = $barcode_content_id; - } - - $htmldata = $self->print_html({ 'cid'=>$content_id, %args }); - } - - $alternative->attach( - 'Type' => 'text/html', - 'Encoding' => 'quoted-printable', - 'Data' => [ '', - ' ', - ' ', - ' '. encode_entities($return{'subject'}), - ' ', - ' ', - ' ', - $htmldata, - ' ', - '', - ], - 'Disposition' => 'inline', - #'Filename' => 'invoice.pdf', - ); - - - my @otherparts = (); - if ( $cust_main->email_csv_cdr ) { - - push @otherparts, build MIME::Entity - 'Type' => 'text/csv', - 'Encoding' => '7bit', - 'Data' => [ map { "$_\n" } - $self->call_details('prepend_billed_number' => 1) - ], - 'Disposition' => 'attachment', - 'Filename' => 'usage-'. $self->invnum. '.csv', - ; - - } - - if ( $conf->exists('invoice_email_pdf') ) { - - #attaching pdf too: - # multipart/mixed - # multipart/related - # multipart/alternative - # text/plain - # text/html - # image/png - # application/pdf - - my $related = build MIME::Entity 'Type' => 'multipart/related', - 'Encoding' => '7bit'; - - #false laziness w/Misc::send_email - $related->head->replace('Content-type', - $related->mime_type. - '; boundary="'. $related->head->multipart_boundary. '"'. - '; type=multipart/alternative' - ); - - $related->add_part($alternative); - - $related->add_part($image) if $image; - - my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args); - - $return{'mimeparts'} = [ $related, $pdf, @otherparts ]; - - } else { - - #no other attachment: - # multipart/related - # multipart/alternative - # text/plain - # text/html - # image/png - - $return{'content-type'} = 'multipart/related'; - if ($conf->exists('invoice-barcode') && $barcode) { - $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ]; - } else { - $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; - } - $return{'type'} = 'multipart/alternative'; #Content-Type of first part... - #$return{'disposition'} = 'inline'; - - } - - } else { - - if ( $conf->exists('invoice_email_pdf') ) { - warn "$me creating PDF attachment" - if $DEBUG; - - #mime parts arguments a la MIME::Entity->build(). - $return{'mimeparts'} = [ - { $self->mimebuild_pdf(\%args) } - ]; - } - - if ( $conf->exists('invoice_email_pdf') - and scalar($conf->config('invoice_email_pdf_note')) ) { - - warn "$me using 'invoice_email_pdf_note'" - if $DEBUG; - $return{'body'} = [ map { $_ . "\n" } - $conf->config('invoice_email_pdf_note') - ]; - - } else { - - warn "$me not using 'invoice_email_pdf_note'" - if $DEBUG; - if ( ref($args{'print_text'}) eq 'ARRAY' ) { - $return{'body'} = $args{'print_text'}; - } else { - $return{'body'} = [ $self->print_text(\%args) ]; - } - - } - - } - - %return; - -} - -=item mimebuild_pdf - -Returns a list suitable for passing to MIME::Entity->build(), representing -this invoice as PDF attachment. - -=cut - -sub mimebuild_pdf { - my $self = shift; - ( - 'Type' => 'application/pdf', - 'Encoding' => 'base64', - 'Data' => [ $self->print_pdf(@_) ], - 'Disposition' => 'attachment', - 'Filename' => 'invoice-'. $self->invnum. '.pdf', - ); -} - =item send HASHREF Sends this invoice to the destinations configured for this customer: sends @@ -1330,11 +1086,11 @@ I