X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=d9e04de53215a92384b2e2c5930bd2ff748ed2b1;hb=f9a181e4c2e505df84de16190ee3b75011326f3f;hp=54641ee358ee97b8d919c60e927cd516ef507424;hpb=88d237b8fe396b4e85a16e20eccfeb4e22e94e24;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 54641ee35..ca35a443a 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,53 +1,58 @@ package FS::cust_bill; 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( @ISA $DEBUG $me $conf + $money_char $date_format $rdate_format $date_format_long ); use vars qw( $invoice_lines @buf ); #yuck +use Fcntl qw(:flock); #for spool_csv +use List::Util qw(min max); use Date::Format; -use Mail::Internet 1.44; -use Mail::Header; -use Text::Template; +use Text::Template 1.20; +use File::Temp 0.14; +use String::ShellQuote; +use HTML::Entities; +use Locale::Country; +use Storable qw( freeze thaw ); +use GD::Barcode; use FS::UID qw( datasrc ); -use FS::Record qw( qsearch qsearchs ); +use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print ); +use FS::Record qw( qsearch qsearchs dbh ); +use FS::cust_main_Mixin; use FS::cust_main; +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::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; +use FS::cust_bill_batch; +use Cwd; -@ISA = qw( FS::Record ); +@ISA = qw( FS::cust_main_Mixin FS::Record ); -#ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_bill'} = sub { +$DEBUG = 0; +$me = '[FS::cust_bill]'; +#ask FS::UID to run this stuff for us later +FS::UID->install_callback( 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('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"; - } - -}; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; #/YY + $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; #/YYYY + $date_format_long = $conf->config('date_format_long') || '%b %o, %Y'; +} ); =head1 NAME @@ -88,6 +93,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) @@ -99,10 +106,38 @@ L and L for conversion functions. =item charged - amount of this invoice +=item invoice_terms - optional terms override for this specific invoice + +=back + +Customer info at invoice generation time + +=over 4 + +=item previous_balance + +=item billing_balance + +=back + +Deprecated + +=over 4 + =item printed - deprecated +=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 + =back =head1 METHODS @@ -119,44 +154,174 @@ Invoices are normally created by calling the bill method of a customer object sub table { 'cust_bill'; } +sub cust_linked { $_[0]->cust_main_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. +=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"; + } + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + =item delete -Currently unimplemented. I don't remove invoices because there would then be -no record you ever posted this invoice (which is bad, no?) +This method now works but you probably shouldn't use it. Instead, apply a +credit against the invoice. + +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 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_bill_event + cust_event + cust_credit_bill + cust_bill_pay + cust_bill_pay + cust_credit_bill + cust_pay_batch + cust_bill_pay_batch + cust_bill_pkg + )) { + + 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->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, @@ -170,22 +335,37 @@ 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? ; 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 ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){ + return $self->agent_invid; + } else { + return $self->invnum; + } } =item previous @@ -214,13 +394,90 @@ 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', + } + ); +} + +=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 -Returns the completed invoice events (see L) for this -invoice. +Returns the completed invoice events (deprecated, old-style events - see L) for this invoice. =cut @@ -229,6 +486,54 @@ sub cust_bill_event { 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. + +=cut + +#false laziness w/cust_pkg.pm +sub cust_event { + my $self = shift; + 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 @@ -241,6 +546,25 @@ sub cust_main { qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); } +=item cust_suspend_if_balance_over AMOUNT + +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: an empty list on success or a list of errors. + +=cut + +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_credit Depreciated. See the cust_credited method. @@ -282,6 +606,16 @@ sub cust_pay { #; } +sub cust_pay_batch { + my $self = shift; + qsearch('cust_pay_batch', { 'invnum' => $self->invnum } ); +} + +sub cust_bill_pay_batch { + my $self = shift; + qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } ); +} + =item cust_bill_pay Returns all payment applications (see L) for this invoice. @@ -290,23 +624,71 @@ 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_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 tax Returns the tax amount (see L) for this invoice. @@ -340,751 +722,4404 @@ sub owed { $balance; } -=item send +sub owed_pkgnum { + my( $self, $pkgnum ) = @_; -Sends this invoice to the destinations configured for this customer: send -emails or print. See L. + #my $balance = $self->charged; + my $balance = 0; + $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); -=cut + $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); + $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); -sub send { - my($self,$template) = @_; - my @print_text = $self->print_text('', $template); - my @invoicing_list = $self->cust_main->invoicing_list; + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} - if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list ) { #email - - #better to notify this person than silence - @invoicing_list = ($invoice_from) unless @invoicing_list; - - #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' => [ @print_text ], #( 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: $!"; +=item apply_payments_and_credits [ OPTION => VALUE ... ] - } +Applies unapplied payments and credits to this invoice. - if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal - open(LPR, "|$lpr") - or return "Can't open pipe to $lpr: $!"; - print LPR @print_text; - close LPR - or return $! ? "Error closing $lpr: $!" - : "Exit status $? from $lpr"; - } +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. - ''; +If there is an error, returns the error, otherwise returns false. -} +=cut -=item send_csv OPTIONS +sub apply_payments_and_credits { + my( $self, %options ) = @_; -Sends invoice as a CSV data-file to a remote host with the specified protocol. + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; -Options are: + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; -protocol - currently only "ftp" -server -username -password -dir + $self->select_for_update; #mutex -The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number -and YYMMDDHHMMSS is a timestamp. + my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay; + my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; -The fields of the CSV file is as follows: + 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; + } -record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate + 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"; + } -=over 4 + my $unapp_amount; + if ( $app eq 'pay' ) { -=item record type - B is either C or C + 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; -If B is C, this is a primary invoice record. The -last five fields (B through B) are irrelevant, and all other -fields are filled in. + } elsif ( $app eq 'credit' ) { -If B is C, this is a line item record. Only the -first two fields (B and B) and the last five fields -(B through B) are filled in. + 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; -=item invnum - invoice number + } else { + die "guru meditation #12 and 35"; + } -=item custnum - customer number + 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; -=item _date - invoice date + warn "min ( $unapp_amount, $owed )\n" if $DEBUG; + $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) ); -=item charged - total invoice amount + $app->invnum( $self->invnum ); -=item first - customer first name + my $error = $app->insert(%options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting ". $app->table. " record: $error"; + } + die $error if $error; -=item last - customer first name + } -=item company - company name + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error -=item address1 - address line 1 +} -=item address2 - address line 1 +=item generate_email OPTION => VALUE ... -=item city +Options: -=item state +=over 4 -=item zip +=item from -=item country +sender address, required -=item pkg - line item description +=item tempate -=item setup - line item setup fee (one or both of B and B will be defined) +alternate template name, optional -=item recur - line item recurring fee (one or both of B and B will be defined) +=item print_text -=item sdate - start date for recurring fee +text attachment arrayref, optional -=item edate - end date for recurring fee +=item subject + +email subject, optional + +=item notice_name + +notice name instead of "Invoice", optional =back -=cut +Returns an argument list to be passed to L. -sub send_csv { - my($self, %opt) = @_; +=cut - #part one: create file +use MIME::Entity; - my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; - mkdir $spooldir, 0700 unless -d $spooldir; +sub generate_email { - my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); + my $self = shift; + my %args = @_; - open(CSV, ">$file") or die "can't open $file: $!"; + my $me = '[FS::cust_bill::generate_email]'; - eval "use Text::CSV_XS"; - die $@ if $@; + my %return = ( + 'from' => $args{'from'}, + 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), + ); - my $csv = Text::CSV_XS->new({'always_quote'=>1}); + my %opt = ( + 'unsquelch_cdr' => $conf->exists('voip-cdr_email'), + 'template' => $args{'template'}, + 'notice_name' => ( $args{'notice_name'} || 'Invoice' ), + ); my $cust_main = $self->cust_main; - $csv->combine( - 'cust_bill', - $self->invnum, - $self->custnum, - time2str("%x", $self->_date), - sprintf("%.2f", $self->charged), - ( map { $cust_main->getfield($_) } - qw( first last company address1 address2 city state zip country ) ), - map { '' } (1..5), - ) or die "can't create csv"; - print CSV $csv->string. "\n"; - - #new charges (false laziness w/print_text) - foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + if (ref($args{'to'}) eq 'ARRAY') { + $return{'to'} = $args{'to'}; + } else { + $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ } + $cust_main->invoicing_list + ]; + } - my($pkg, $setup, $recur, $sdate, $edate); - if ( $cust_bill_pkg->pkgnum ) { - - ($pkg, $setup, $recur, $sdate, $edate) = ( - $cust_bill_pkg->cust_pkg->part_pkg->pkg, - ( $cust_bill_pkg->setup != 0 - ? sprintf("%.2f", $cust_bill_pkg->setup ) - : '' ), - ( $cust_bill_pkg->recur != 0 - ? sprintf("%.2f", $cust_bill_pkg->recur ) - : '' ), - time2str("%x", $cust_bill_pkg->sdate), - time2str("%x", $cust_bill_pkg->edate), - ); + if ( $conf->exists('invoice_html') ) { - } else { #pkgnum tax - next unless $cust_bill_pkg->setup != 0; - my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc') - ? ( $cust_bill_pkg->itemdesc || 'Tax' ) - : 'Tax'; - ($pkg, $setup, $recur, $sdate, $edate) = - ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' ); + 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(\%opt) ]; + } + + } + + $alternative->attach( + 'Type' => 'text/plain', + #'Encoding' => 'quoted-printable', + 'Encoding' => '7bit', + 'Data' => $data, + 'Disposition' => 'inline', + ); + + $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); + + my $image = build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $image_data, + 'Filename' => 'logo.png', + 'Content-ID' => "<$content_id>", + ; + + my $barcode; + 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>", + ; + $opt{'barcode_cid'} = $barcode_content_id; + } + + $alternative->attach( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', + 'Data' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + $self->print_html({ 'cid'=>$content_id, %opt }), + ' ', + '', + ], + '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); + + my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt); + + $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')){ + $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(\%opt) } + ]; + } + + 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(\%opt) ]; + } + + } + + } + + %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 | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ] + +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 (recommended) or as a list of up to +four values for templatename, agentnum, invoice_from and amount. + +I