package FS::cust_bill; use strict; 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 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::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::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') || '$'; $date_format = $conf->config('date_format') || '%x'; $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; } ); =head1 NAME FS::cust_bill - Object methods for cust_bill records =head1 SYNOPSIS use FS::cust_bill; $record = new FS::cust_bill \%hash; $record = new FS::cust_bill { 'column' => 'value' }; $error = $record->insert; $error = $new_record->replace($old_record); $error = $record->delete; $error = $record->check; ( $total_previous_balance, @previous_cust_bill ) = $record->previous; @cust_bill_pkg_objects = $cust_bill->cust_bill_pkg; ( $total_previous_credits, @previous_cust_credit ) = $record->cust_credit; @cust_pay_objects = $cust_bill->cust_pay; $tax_amount = $record->tax; @lines = $cust_bill->print_text; @lines = $cust_bill->print_text $time; =head1 DESCRIPTION An FS::cust_bill object represents an invoice; a declaration that a customer 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) =item custnum - customer (see L) =item _date - specified as a UNIX timestamp; see L. Also see 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 =over 4 =item new HASHREF Creates a new invoice. To add the invoice to the database, see L<"insert">. Invoices are normally created by calling the bill method of a customer object (see L). =cut 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. =item delete 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; 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 Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. Only printed may be changed. printed is normally updated by calling the collect method of a customer object (see L). =cut #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 || $old->charged == 0; ''; } =item check Checks all fields to make sure this is a valid invoice. If there is an error, returns the error, otherwise returns false. Called by the insert and replace methods. =cut sub check { my $self = shift; my $error = $self->ut_numbern('invnum') || $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; $self->_date(time) unless $self->_date; $self->printed(0) if $self->printed eq ''; $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, 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 && $_->_date < $self->_date } qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ; foreach ( @cust_bill ) { $total += $_->owed; } $total, @cust_bill; } =item cust_bill_pkg Returns the line items (see L) for this invoice. =cut sub cust_bill_pkg { my $self = shift; 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 (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. =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 Returns the customer (see L) for this invoice. =cut sub cust_main { my $self = shift; 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. #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). =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. #Returns all payments (see L) for this invoice. =cut sub cust_pay { use Carp; croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay"; #my $self = shift; #sort { $a->_date <=> $b->_date } # qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) #; } sub 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. =cut 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. =cut sub tax { my $self = shift; my $total = 0; my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum , 'pkgnum' => 0 } ); foreach (@taxlines) { $total += $_->setup; } $total; } =item owed Returns the amount owed (still outstanding) on this invoice, which is charged minus all payment applications (see L) and credit applications (see L). =cut sub owed { my $self = shift; my $balance = $self->charged; $balance -= $_->amount foreach ( $self->cust_bill_pay ); $balance -= $_->amount foreach ( $self->cust_credited ); $balance = sprintf( "%.2f", $balance); $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp $balance; } sub owed_pkgnum { my( $self, $pkgnum ) = @_; #my $balance = $self->charged; my $balance = 0; $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); $balance = sprintf( "%.2f", $balance); $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp $balance; } =item apply_payments_and_credits [ OPTION => VALUE ... ] Applies unapplied payments and credits to this invoice. 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 sub apply_payments_and_credits { my( $self, %options ) = @_; local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; local $SIG{QUIT} = 'IGNORE'; local $SIG{TERM} = 'IGNORE'; local $SIG{TSTP} = 'IGNORE'; local $SIG{PIPE} = 'IGNORE'; my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; $self->select_for_update; #mutex my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay; my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; if ( $conf->exists('pkg-balances') ) { # limit @payments & @credits to those w/ a pkgnum grepped from $self my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg; @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments; @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits; } while ( $self->owed > 0 and ( @payments || @credits ) ) { my $app = ''; if ( @payments && @credits ) { #decide which goes first by weight of top (unapplied) line item my @open_lineitems = $self->open_cust_bill_pkg; my $max_pay_weight = max( map { $_->part_pkg->pay_weight || 0 } grep { $_ } map { $_->cust_pkg } @open_lineitems ); my $max_credit_weight = max( map { $_->part_pkg->credit_weight || 0 } grep { $_ } map { $_->cust_pkg } @open_lineitems ); #if both are the same... payments first? it has to be something if ( $max_pay_weight >= $max_credit_weight ) { $app = 'pay'; } else { $app = 'credit'; } } elsif ( @payments ) { $app = 'pay'; } elsif ( @credits ) { $app = 'credit'; } else { die "guru meditation #12 and 35"; } my $unapp_amount; if ( $app eq 'pay' ) { 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; } else { die "guru meditation #12 and 35"; } my $owed; if ( $conf->exists('pkg-balances') && $app->pkgnum ) { warn "owed_pkgnum ". $app->pkgnum; $owed = $self->owed_pkgnum($app->pkgnum); } else { $owed = $self->owed; } next unless $owed > 0; warn "min ( $unapp_amount, $owed )\n" if $DEBUG; $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) ); $app->invnum( $self->invnum ); my $error = $app->insert(%options); if ( $error ) { $dbh->rollback if $oldAutoCommit; return "Error inserting ". $app->table. " record: $error"; } die $error if $error; } $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; #no error } =item generate_email OPTION => VALUE ... Options: =over 4 =item from sender address, required =item tempate 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 $me = '[FS::cust_bill::generate_email]'; my %return = ( 'from' => $args{'from'}, 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'), ); my %opt = ( 'unsquelch_cdr' => $conf->exists('voip-cdr_email'), 'template' => $args{'template'}, 'notice_name' => ( $args{'notice_name'} || 'Invoice' ), ); 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(\%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>", ; $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'; $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