X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=f10a5d0c42226818e45a8c35dc735be9cc353536;hb=08b36523ebbf6e2995878f26bfac988f32f7a218;hp=e70bb324740ee9502c4bd805bbe82f3b4195916d;hpb=a638ae8bdba35169f61f2729d8f3491496992b55;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index e70bb3247..f10a5d0c4 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,30 +1,51 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $conf $money_char ); +use vars qw( @ISA $DEBUG $me $conf $money_char $date_format $rdate_format ); use vars qw( $invoice_lines @buf ); #yuck +use Fcntl qw(:flock); #for spool_csv +use List::Util qw(min max); use Date::Format; -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 FS::UID qw( datasrc ); -use FS::Record qw( qsearch qsearchs ); -use FS::Misc qw( send_email ); +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; -@ISA = qw( FS::Record ); +@ISA = qw( FS::cust_main_Mixin FS::Record ); + +$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') || '$'; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; + $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; } ); =head1 NAME @@ -66,6 +87,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) @@ -77,10 +100,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 @@ -97,6 +148,13 @@ 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, @@ -104,15 +162,64 @@ returns the error, otherwise returns false. =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 @@ -125,14 +232,20 @@ collect method of a customer object (see L). =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 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 charged!" unless $old->charged == $new->charged + || $old->charged == 0; - $new->SUPER::replace($old); + ''; } =item check @@ -148,17 +261,16 @@ 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 ''; @@ -166,6 +278,22 @@ sub check { $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 Returns a list consisting of the total previous balance for this customer, @@ -192,13 +320,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 @@ -207,6 +412,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 @@ -219,6 +472,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. @@ -260,6 +532,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. @@ -268,23 +550,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. @@ -318,1068 +648,3189 @@ sub owed { $balance; } +sub owed_pkgnum { + my( $self, $pkgnum ) = @_; -=item generate_email PARAMHASH + #my $balance = $self->charged; + my $balance = 0; + $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); -PARAMHASH can contain the following: + $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); + $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); -=over 4 + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} -=item from => sender address, required +=item apply_payments_and_credits [ OPTION => VALUE ... ] -=item tempate => alternate template name, optional +Applies unapplied payments and credits to this invoice. -=item print_text => text attachment arrayref, optional +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. -=item subject => email subject, optional +If there is an error, returns the error, otherwise returns false. -=back +=cut -Returns an argument list to be passed to L. +sub apply_payments_and_credits { + my( $self, %options ) = @_; -=cut + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; -sub generate_email { + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; - my $self = shift; - my %args = @_; + $self->select_for_update; #mutex - my $mimeparts; - if ($conf->exists('invoice_email_pdf')) { - #warn "[FS::cust_bill::send] creating PDF attachment"; - #mime parts arguments a la MIME::Entity->build(). - $mimeparts = [ - { - 'Type' => 'application/pdf', - 'Encoding' => 'base64', - 'Data' => [ $self->print_pdf('', $args{'template'}) ], - 'Disposition' => 'attachment', - 'Filename' => 'invoice.pdf', - }, - ]; - } + my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay; + my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; - my $email_text; - if ($conf->exists('invoice_email_pdf') - and scalar($conf->config('invoice_email_pdf_note'))) { + 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; + } - #warn "[FS::cust_bill::send] using 'invoice_email_pdf_note'"; - $email_text = [ map { $_ . "\n" } $conf->config('invoice_email_pdf_note') ]; - } else { - #warn "[FS::cust_bill::send] not using 'invoice_email_pdf_note'"; - if (ref($args{'print_text'}) eq 'ARRAY') { - $email_text = $args{'print_text'}; + 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 { - $email_text = [ $self->print_text('', $args{'template'}) ]; + die "guru meditation #12 and 35"; } - } - my @invoicing_list; - if (ref($args{'to'} eq 'ARRAY')) { - @invoicing_list = @{$args{'to'}}; - } else { - @invoicing_list = grep { $_ ne 'POST' } $self->cust_main->invoicing_list; - } + my $unapp_amount; + if ( $app eq 'pay' ) { - return ( - 'from' => $args{'from'}, - 'to' => [ @invoicing_list ], - 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), - 'body' => $email_text, - 'mimeparts' => $mimeparts, - ); + 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; + } elsif ( $app eq 'credit' ) { -} + 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 send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ] + } else { + die "guru meditation #12 and 35"; + } -Sends this invoice to the destinations configured for this customer: send -emails or print. See L. + 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; -TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. + warn "min ( $unapp_amount, $owed )\n" if $DEBUG; + $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) ); -AGENTNUM, if specified, means that this invoice will only be sent for customers -of the specified agent. + $app->invnum( $self->invnum ); -INVOICE_FROM, if specified, overrides the default email invoice From: address. + my $error = $app->insert(%options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting ". $app->table. " record: $error"; + } + die $error if $error; -=cut + } -sub send { - my $self = shift; - my $template = scalar(@_) ? shift : ''; - return 'N/A' if scalar(@_) && $_[0] && $self->cust_main->agentnum != shift; - my $invoice_from = - scalar(@_) - ? shift - : ( $self->_agent_invoice_from || $conf->config('invoice_from') ); + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error - my @print_text = $self->print_text('', $template); - my @invoicing_list = $self->cust_main->invoicing_list; +} - if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list ) { #email +=item generate_email OPTION => VALUE ... - #better to notify this person than silence - @invoicing_list = ($invoice_from) unless @invoicing_list; +Options: - my $error = send_email( - $self->generate_email( - 'from' => $invoice_from, - 'to' => [ grep { $_ ne 'POST' } @invoicing_list ], - 'print_text' => [ @print_text ], - ) - ); - die "can't email invoice: $error\n" if $error; - #die "$error\n" if $error; +=over 4 - } +=item from - if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal - @print_text = $self->print_ps('', $template) - if $conf->config('invoice_latex'); - my $lpr = $conf->config('lpr'); - open(LPR, "|$lpr") - or die "Can't open pipe to $lpr: $!\n"; - print LPR @print_text; - close LPR - or die $! ? "Error closing $lpr: $!\n" - : "Exit status $? from $lpr\n"; - } +sender address, required - ''; +=item tempate -} +alternate template name, optional -=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ] +=item print_text -Like B, but only sends the invoice if it is the newest open invoice for -this customer. +text attachment arrayref, optional -=cut +=item subject -sub send_if_newest { - my $self = shift; +email subject, optional - return '' - if scalar( - grep { $_->owed > 0 } - qsearch('cust_bill', { - 'custnum' => $self->custnum, - #'_date' => { op=>'>', value=>$self->_date }, - 'invnum' => { op=>'>', value=>$self->invnum }, - } ) - ); - - $self->send(@_); -} +=item notice_name -=item send_csv OPTIONS +notice name instead of "Invoice", optional -Sends invoice as a CSV data-file to a remote host with the specified protocol. +=back -Options are: +Returns an argument list to be passed to L. -protocol - currently only "ftp" -server -username -password -dir +=cut -The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number -and YYMMDDHHMMSS is a timestamp. +use MIME::Entity; -The fields of the CSV file is as follows: +sub generate_email { -record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate + my $self = shift; + my %args = @_; -=over 4 + my $me = '[FS::cust_bill::generate_email]'; -=item record type - B is either C or C + my %return = ( + 'from' => $args{'from'}, + 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), + ); -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. + my %opt = ( + 'unsquelch_cdr' => $conf->exists('voip-cdr_email'), + 'template' => $args{'template'}, + 'notice_name' => ( $args{'notice_name'} || 'Invoice' ), + ); -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 $cust_main = $self->cust_main; -=item invnum - invoice number + if (ref($args{'to'}) eq 'ARRAY') { + $return{'to'} = $args{'to'}; + } else { + $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ } + $cust_main->invoicing_list + ]; + } -=item custnum - customer number + if ( $conf->exists('invoice_html') ) { -=item _date - invoice date + warn "$me creating HTML/text multipart message" + if $DEBUG; -=item charged - total invoice amount + $return{'nobody'} = 1; -=item first - customer first name + my $alternative = build MIME::Entity + 'Type' => 'multipart/alternative', + 'Encoding' => '7bit', + 'Disposition' => 'inline' + ; -=item last - customer first name + my $data; + if ( $conf->exists('invoice_email_pdf') + and scalar($conf->config('invoice_email_pdf_note')) ) { -=item company - company name + warn "$me using 'invoice_email_pdf_note' in multipart message" + if $DEBUG; + $data = [ map { $_ . "\n" } + $conf->config('invoice_email_pdf_note') + ]; -=item address1 - address line 1 + } else { -=item address2 - address line 1 + 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) ]; + } -=item city + } -=item state + $alternative->attach( + 'Type' => 'text/plain', + #'Encoding' => 'quoted-printable', + 'Encoding' => '7bit', + 'Data' => $data, + 'Disposition' => 'inline', + ); -=item zip + $args{'from'} =~ /\@([\w\.\-]+)/; + my $from = $1 || 'example.com'; + my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; -=item country + 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>", + ; + + $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', + ); -=item pkg - line item description + my @otherparts = (); + if ( $cust_main->email_csv_cdr ) { -=item setup - line item setup fee (one or both of B and B will be defined) + 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', + ; -=item recur - line item recurring fee (one or both of B and B will be defined) + } -=item sdate - start date for recurring fee + 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' + ); -=item edate - end date for recurring fee + $related->add_part($alternative); -=back + $related->add_part($image); -=cut + my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt); -sub send_csv { - my($self, %opt) = @_; + $return{'mimeparts'} = [ $related, $pdf, @otherparts ]; - #part one: create file + } else { - my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; - mkdir $spooldir, 0700 unless -d $spooldir; + #no other attachment: + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png - my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); + $return{'content-type'} = 'multipart/related'; + $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; + $return{'type'} = 'multipart/alternative'; #Content-Type of first part... + #$return{'disposition'} = 'inline'; - open(CSV, ">$file") or die "can't open $file: $!"; + } + + } else { - eval "use Text::CSV_XS"; - die $@ if $@; + if ( $conf->exists('invoice_email_pdf') ) { + warn "$me creating PDF attachment" + if $DEBUG; - my $csv = Text::CSV_XS->new({'always_quote'=>1}); + #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')) ) { - my $cust_main = $self->cust_main; + warn "$me using 'invoice_email_pdf_note'" + if $DEBUG; + $return{'body'} = [ map { $_ . "\n" } + $conf->config('invoice_email_pdf_note') + ]; - $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 ) { + } else { - 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), - ); + 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) ]; + } - } 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), '', '', '' ); } - $csv->combine( - 'cust_bill_pkg', - $self->invnum, - ( map { '' } (1..11) ), - ($pkg, $setup, $recur, $sdate, $edate) - ) or die "can't create csv"; - print CSV $csv->string. "\n"; - } - close CSV or die "can't close CSV: $!"; + %return; - #part two: upload it +} - my $net; - if ( $opt{protocol} eq 'ftp' ) { - eval "use Net::FTP;"; - die $@ if $@; - $net = Net::FTP->new($opt{server}) or die @$; - } else { - die "unknown protocol: $opt{protocol}"; - } +=item mimebuild_pdf - $net->login( $opt{username}, $opt{password} ) - or die "can't FTP to $opt{username}\@$opt{server}: login error: $@"; +Returns a list suitable for passing to MIME::Entity->build(), representing +this invoice as PDF attachment. - $net->binary or die "can't set binary mode"; +=cut - $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}"; +sub mimebuild_pdf { + my $self = shift; + ( + 'Type' => 'application/pdf', + 'Encoding' => 'base64', + 'Data' => [ $self->print_pdf(@_) ], + 'Disposition' => 'attachment', + 'Filename' => 'invoice-'. $self->invnum. '.pdf', + ); +} - $net->put($file) or die "can't put $file: $!"; +=item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ] - $net->quit; +Sends this invoice to the destinations configured for this customer: sends +email, prints and/or faxes. See L. - unlink $file; +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