2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::Record );
5 use vars qw( $DEBUG $me $date_format );
7 use Fcntl qw(:flock); #for spool_csv
9 use List::Util qw(min max);
13 use Storable qw( freeze thaw );
15 use FS::UID qw( datasrc );
16 use FS::Misc qw( send_email send_fax do_print );
17 use FS::Record qw( qsearch qsearchs dbh );
19 use FS::cust_statement;
20 use FS::cust_bill_pkg;
21 use FS::cust_bill_pkg_display;
22 use FS::cust_bill_pkg_detail;
26 use FS::cust_credit_bill;
28 use FS::cust_pay_batch;
29 use FS::cust_bill_event;
32 use FS::cust_bill_pay;
33 use FS::cust_bill_pay_batch;
34 use FS::part_bill_event;
37 use FS::cust_bill_batch;
38 use FS::cust_bill_pay_pkg;
39 use FS::cust_credit_bill_pkg;
40 use FS::discount_plan;
44 $me = '[FS::cust_bill]';
46 #ask FS::UID to run this stuff for us later
47 FS::UID->install_callback( sub {
48 my $conf = new FS::Conf; #global
49 $date_format = $conf->config('date_format') || '%x'; #/YY
54 FS::cust_bill - Object methods for cust_bill records
60 $record = new FS::cust_bill \%hash;
61 $record = new FS::cust_bill { 'column' => 'value' };
63 $error = $record->insert;
65 $error = $new_record->replace($old_record);
67 $error = $record->delete;
69 $error = $record->check;
71 ( $total_previous_balance, @previous_cust_bill ) = $record->previous;
73 @cust_bill_pkg_objects = $cust_bill->cust_bill_pkg;
75 ( $total_previous_credits, @previous_cust_credit ) = $record->cust_credit;
77 @cust_pay_objects = $cust_bill->cust_pay;
79 $tax_amount = $record->tax;
81 @lines = $cust_bill->print_text;
82 @lines = $cust_bill->print_text $time;
86 An FS::cust_bill object represents an invoice; a declaration that a customer
87 owes you money. The specific charges are itemized as B<cust_bill_pkg> records
88 (see L<FS::cust_bill_pkg>). FS::cust_bill inherits from FS::Record. The
89 following fields are currently supported:
95 =item invnum - primary key (assigned automatically for new invoices)
97 =item custnum - customer (see L<FS::cust_main>)
99 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
100 L<Time::Local> and L<Date::Parse> for conversion functions.
102 =item charged - amount of this invoice
104 =item invoice_terms - optional terms override for this specific invoice
108 Customer info at invoice generation time
112 =item previous_balance
114 =item billing_balance
122 =item printed - deprecated
130 =item closed - books closed flag, empty or `Y'
132 =item statementnum - invoice aggregation (see L<FS::cust_statement>)
134 =item agent_invid - legacy invoice number
136 =item promised_date - customer promised payment date, for collection
146 Creates a new invoice. To add the invoice to the database, see L<"insert">.
147 Invoices are normally created by calling the bill method of a customer object
148 (see L<FS::cust_main>).
152 sub table { 'cust_bill'; }
153 sub notice_name { 'Invoice'; }
155 sub cust_linked { $_[0]->cust_main_custnum; }
156 sub cust_unlinked_msg {
158 "WARNING: can't find cust_main.custnum ". $self->custnum.
159 ' (cust_bill.invnum '. $self->invnum. ')';
164 Adds this invoice to the database ("Posts" the invoice). If there is an error,
165 returns the error, otherwise returns false.
171 warn "$me insert called\n" if $DEBUG;
173 local $SIG{HUP} = 'IGNORE';
174 local $SIG{INT} = 'IGNORE';
175 local $SIG{QUIT} = 'IGNORE';
176 local $SIG{TERM} = 'IGNORE';
177 local $SIG{TSTP} = 'IGNORE';
178 local $SIG{PIPE} = 'IGNORE';
180 my $oldAutoCommit = $FS::UID::AutoCommit;
181 local $FS::UID::AutoCommit = 0;
184 my $error = $self->SUPER::insert;
186 $dbh->rollback if $oldAutoCommit;
190 if ( $self->get('cust_bill_pkg') ) {
191 foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
192 $cust_bill_pkg->invnum($self->invnum);
193 my $error = $cust_bill_pkg->insert;
195 $dbh->rollback if $oldAutoCommit;
196 return "can't create invoice line item: $error";
201 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
208 This method now works but you probably shouldn't use it. Instead, apply a
209 credit against the invoice.
211 Using this method to delete invoices outright is really, really bad. There
212 would be no record you ever posted this invoice, and there are no check to
213 make sure charged = 0 or that there are no associated cust_bill_pkg records.
215 Really, don't use it.
221 return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
223 local $SIG{HUP} = 'IGNORE';
224 local $SIG{INT} = 'IGNORE';
225 local $SIG{QUIT} = 'IGNORE';
226 local $SIG{TERM} = 'IGNORE';
227 local $SIG{TSTP} = 'IGNORE';
228 local $SIG{PIPE} = 'IGNORE';
230 my $oldAutoCommit = $FS::UID::AutoCommit;
231 local $FS::UID::AutoCommit = 0;
234 foreach my $table (qw(
246 foreach my $linked ( $self->$table() ) {
247 my $error = $linked->delete;
249 $dbh->rollback if $oldAutoCommit;
256 my $error = $self->SUPER::delete(@_);
258 $dbh->rollback if $oldAutoCommit;
262 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
268 =item replace [ OLD_RECORD ]
270 You can, but probably shouldn't modify invoices...
272 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
273 supplied, replaces this record. If there is an error, returns the error,
274 otherwise returns false.
278 #replace can be inherited from Record.pm
280 # replace_check is now the preferred way to #implement replace data checks
281 # (so $object->replace() works without an argument)
284 my( $new, $old ) = ( shift, shift );
285 return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
286 #return "Can't change _date!" unless $old->_date eq $new->_date;
287 return "Can't change _date" unless $old->_date == $new->_date;
288 return "Can't change charged" unless $old->charged == $new->charged
289 || $old->charged == 0
290 || $new->{'Hash'}{'cc_surcharge_replace_hack'};
296 =item add_cc_surcharge
302 sub add_cc_surcharge {
303 my ($self, $pkgnum, $amount) = (shift, shift, shift);
306 my $cust_bill_pkg = new FS::cust_bill_pkg({
307 'invnum' => $self->invnum,
311 $error = $cust_bill_pkg->insert;
312 return $error if $error;
314 $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
315 $self->charged($self->charged+$amount);
316 $error = $self->replace;
317 return $error if $error;
319 $self->apply_payments_and_credits;
325 Checks all fields to make sure this is a valid invoice. If there is an error,
326 returns the error, otherwise returns false. Called by the insert and replace
335 $self->ut_numbern('invnum')
336 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
337 || $self->ut_numbern('_date')
338 || $self->ut_money('charged')
339 || $self->ut_numbern('printed')
340 || $self->ut_enum('closed', [ '', 'Y' ])
341 || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
342 || $self->ut_numbern('agent_invid') #varchar?
344 return $error if $error;
346 $self->_date(time) unless $self->_date;
348 $self->printed(0) if $self->printed eq '';
355 Returns the displayed invoice number for this invoice: agent_invid if
356 cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
362 my $conf = $self->conf;
363 if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
364 return $self->agent_invid;
366 return $self->invnum;
372 Returns a list consisting of the total previous balance for this customer,
373 followed by the previous outstanding invoices (as FS::cust_bill objects also).
380 my @cust_bill = sort { $a->_date <=> $b->_date }
381 grep { $_->owed != 0 }
382 qsearch( 'cust_bill', { 'custnum' => $self->custnum,
383 '_date' => { op=>'<', value=>$self->_date },
386 foreach ( @cust_bill ) { $total += $_->owed; }
392 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
399 { 'table' => 'cust_bill_pkg',
400 'hashref' => { 'invnum' => $self->invnum },
401 'order_by' => 'ORDER BY billpkgnum',
406 =item cust_bill_pkg_pkgnum PKGNUM
408 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
413 sub cust_bill_pkg_pkgnum {
414 my( $self, $pkgnum ) = @_;
416 { 'table' => 'cust_bill_pkg',
417 'hashref' => { 'invnum' => $self->invnum,
420 'order_by' => 'ORDER BY billpkgnum',
427 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
434 my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
435 $self->cust_bill_pkg;
437 grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
442 Returns true if any of the packages (or their definitions) corresponding to the
443 line items for this invoice have the no_auto flag set.
449 grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
452 =item open_cust_bill_pkg
454 Returns the open line items for this invoice.
456 Note that cust_bill_pkg with both setup and recur fees are returned as two
457 separate line items, each with only one fee.
461 # modeled after cust_main::open_cust_bill
462 sub open_cust_bill_pkg {
465 # grep { $_->owed > 0 } $self->cust_bill_pkg
467 my %other = ( 'recur' => 'setup',
468 'setup' => 'recur', );
470 foreach my $field ( qw( recur setup )) {
471 push @open, map { $_->set( $other{$field}, 0 ); $_; }
472 grep { $_->owed($field) > 0 }
473 $self->cust_bill_pkg;
479 =item cust_bill_event
481 Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
485 sub cust_bill_event {
487 qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
490 =item num_cust_bill_event
492 Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
496 sub num_cust_bill_event {
499 "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
500 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
501 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
502 $sth->fetchrow_arrayref->[0];
507 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
511 #false laziness w/cust_pkg.pm
515 'table' => 'cust_event',
516 'addl_from' => 'JOIN part_event USING ( eventpart )',
517 'hashref' => { 'tablenum' => $self->invnum },
518 'extra_sql' => " AND eventtable = 'cust_bill' ",
524 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
528 #false laziness w/cust_pkg.pm
532 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
533 " WHERE tablenum = ? AND eventtable = 'cust_bill'";
534 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
535 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
536 $sth->fetchrow_arrayref->[0];
541 Returns the customer (see L<FS::cust_main>) for this invoice.
547 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
550 =item cust_suspend_if_balance_over AMOUNT
552 Suspends the customer associated with this invoice if the total amount owed on
553 this invoice and all older invoices is greater than the specified amount.
555 Returns a list: an empty list on success or a list of errors.
559 sub cust_suspend_if_balance_over {
560 my( $self, $amount ) = ( shift, shift );
561 my $cust_main = $self->cust_main;
562 if ( $cust_main->total_owed_date($self->_date) < $amount ) {
565 $cust_main->suspend(@_);
571 Depreciated. See the cust_credited method.
573 #Returns a list consisting of the total previous credited (see
574 #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
575 #outstanding credits (FS::cust_credit objects).
581 croak "FS::cust_bill->cust_credit depreciated; see ".
582 "FS::cust_bill->cust_credit_bill";
585 #my @cust_credit = sort { $a->_date <=> $b->_date }
586 # grep { $_->credited != 0 && $_->_date < $self->_date }
587 # qsearch('cust_credit', { 'custnum' => $self->custnum } )
589 #foreach (@cust_credit) { $total += $_->credited; }
590 #$total, @cust_credit;
595 Depreciated. See the cust_bill_pay method.
597 #Returns all payments (see L<FS::cust_pay>) for this invoice.
603 croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
605 #sort { $a->_date <=> $b->_date }
606 # qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
612 qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
615 sub cust_bill_pay_batch {
617 qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
622 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
628 map { $_ } #return $self->num_cust_bill_pay unless wantarray;
629 sort { $a->_date <=> $b->_date }
630 qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
635 =item cust_credit_bill
637 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
643 map { $_ } #return $self->num_cust_credit_bill unless wantarray;
644 sort { $a->_date <=> $b->_date }
645 qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
649 sub cust_credit_bill {
650 shift->cust_credited(@_);
653 #=item cust_bill_pay_pkgnum PKGNUM
655 #Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
656 #with matching pkgnum.
660 #sub cust_bill_pay_pkgnum {
661 # my( $self, $pkgnum ) = @_;
662 # map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
663 # sort { $a->_date <=> $b->_date }
664 # qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
665 # 'pkgnum' => $pkgnum,
670 =item cust_bill_pay_pkg PKGNUM
672 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
673 applied against the matching pkgnum.
677 sub cust_bill_pay_pkg {
678 my( $self, $pkgnum ) = @_;
681 'select' => 'cust_bill_pay_pkg.*',
682 'table' => 'cust_bill_pay_pkg',
683 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
684 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
685 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
686 " AND cust_bill_pkg.pkgnum = $pkgnum",
691 #=item cust_credited_pkgnum PKGNUM
693 #=item cust_credit_bill_pkgnum PKGNUM
695 #Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
696 #with matching pkgnum.
700 #sub cust_credited_pkgnum {
701 # my( $self, $pkgnum ) = @_;
702 # map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
703 # sort { $a->_date <=> $b->_date }
704 # qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
705 # 'pkgnum' => $pkgnum,
710 #sub cust_credit_bill_pkgnum {
711 # shift->cust_credited_pkgnum(@_);
714 =item cust_credit_bill_pkg PKGNUM
716 Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
717 applied against the matching pkgnum.
721 sub cust_credit_bill_pkg {
722 my( $self, $pkgnum ) = @_;
725 'select' => 'cust_credit_bill_pkg.*',
726 'table' => 'cust_credit_bill_pkg',
727 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
728 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
729 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
730 " AND cust_bill_pkg.pkgnum = $pkgnum",
735 =item cust_bill_batch
737 Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
741 sub cust_bill_batch {
743 qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
748 Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
749 hash keyed by term length.
755 FS::discount_plan->all($self);
760 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
767 my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
769 foreach (@taxlines) { $total += $_->setup; }
775 Returns the amount owed (still outstanding) on this invoice, which is charged
776 minus all payment applications (see L<FS::cust_bill_pay>) and credit
777 applications (see L<FS::cust_credit_bill>).
783 my $balance = $self->charged;
784 $balance -= $_->amount foreach ( $self->cust_bill_pay );
785 $balance -= $_->amount foreach ( $self->cust_credited );
786 $balance = sprintf( "%.2f", $balance);
787 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
792 my( $self, $pkgnum ) = @_;
794 #my $balance = $self->charged;
796 $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
798 $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum);
799 $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum);
801 $balance = sprintf( "%.2f", $balance);
802 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
808 Returns true if this invoice should be hidden. See the
809 selfservice-hide_invoices-taxclass configuraiton setting.
815 my $conf = $self->conf;
816 my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
818 my @cust_bill_pkg = $self->cust_bill_pkg;
819 my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
820 ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
823 =item apply_payments_and_credits [ OPTION => VALUE ... ]
825 Applies unapplied payments and credits to this invoice.
827 A hash of optional arguments may be passed. Currently "manual" is supported.
828 If true, a payment receipt is sent instead of a statement when
829 'payment_receipt_email' configuration option is set.
831 If there is an error, returns the error, otherwise returns false.
835 sub apply_payments_and_credits {
836 my( $self, %options ) = @_;
837 my $conf = $self->conf;
839 local $SIG{HUP} = 'IGNORE';
840 local $SIG{INT} = 'IGNORE';
841 local $SIG{QUIT} = 'IGNORE';
842 local $SIG{TERM} = 'IGNORE';
843 local $SIG{TSTP} = 'IGNORE';
844 local $SIG{PIPE} = 'IGNORE';
846 my $oldAutoCommit = $FS::UID::AutoCommit;
847 local $FS::UID::AutoCommit = 0;
850 $self->select_for_update; #mutex
852 my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
853 my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
855 if ( $conf->exists('pkg-balances') ) {
856 # limit @payments & @credits to those w/ a pkgnum grepped from $self
857 my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
858 @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
859 @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
862 while ( $self->owed > 0 and ( @payments || @credits ) ) {
865 if ( @payments && @credits ) {
867 #decide which goes first by weight of top (unapplied) line item
869 my @open_lineitems = $self->open_cust_bill_pkg;
872 max( map { $_->part_pkg->pay_weight || 0 }
877 my $max_credit_weight =
878 max( map { $_->part_pkg->credit_weight || 0 }
884 #if both are the same... payments first? it has to be something
885 if ( $max_pay_weight >= $max_credit_weight ) {
891 } elsif ( @payments ) {
893 } elsif ( @credits ) {
896 die "guru meditation #12 and 35";
900 if ( $app eq 'pay' ) {
902 my $payment = shift @payments;
903 $unapp_amount = $payment->unapplied;
904 $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum };
905 $app->pkgnum( $payment->pkgnum )
906 if $conf->exists('pkg-balances') && $payment->pkgnum;
908 } elsif ( $app eq 'credit' ) {
910 my $credit = shift @credits;
911 $unapp_amount = $credit->credited;
912 $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
913 $app->pkgnum( $credit->pkgnum )
914 if $conf->exists('pkg-balances') && $credit->pkgnum;
917 die "guru meditation #12 and 35";
921 if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
922 warn "owed_pkgnum ". $app->pkgnum;
923 $owed = $self->owed_pkgnum($app->pkgnum);
927 next unless $owed > 0;
929 warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
930 $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
932 $app->invnum( $self->invnum );
934 my $error = $app->insert(%options);
936 $dbh->rollback if $oldAutoCommit;
937 return "Error inserting ". $app->table. " record: $error";
939 die $error if $error;
943 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
948 =item generate_email OPTION => VALUE ...
956 sender address, required
960 alternate template name, optional
964 text attachment arrayref, optional
968 email subject, optional
972 notice name instead of "Invoice", optional
976 Returns an argument list to be passed to L<FS::Misc::send_email>.
986 my $conf = $self->conf;
988 my $me = '[FS::cust_bill::generate_email]';
991 'from' => $args{'from'},
992 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
996 'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
997 'template' => $args{'template'},
998 'notice_name' => ( $args{'notice_name'} || 'Invoice' ),
999 'no_coupon' => $args{'no_coupon'},
1002 my $cust_main = $self->cust_main;
1004 if (ref($args{'to'}) eq 'ARRAY') {
1005 $return{'to'} = $args{'to'};
1007 $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
1008 $cust_main->invoicing_list
1012 if ( $conf->exists('invoice_html') ) {
1014 warn "$me creating HTML/text multipart message"
1017 $return{'nobody'} = 1;
1019 my $alternative = build MIME::Entity
1020 'Type' => 'multipart/alternative',
1021 #'Encoding' => '7bit',
1022 'Disposition' => 'inline'
1026 if ( $conf->exists('invoice_email_pdf')
1027 and scalar($conf->config('invoice_email_pdf_note')) ) {
1029 warn "$me using 'invoice_email_pdf_note' in multipart message"
1031 $data = [ map { $_ . "\n" }
1032 $conf->config('invoice_email_pdf_note')
1037 warn "$me not using 'invoice_email_pdf_note' in multipart message"
1039 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1040 $data = $args{'print_text'};
1042 $data = [ $self->print_text(\%opt) ];
1047 $alternative->attach(
1048 'Type' => 'text/plain',
1049 'Encoding' => 'quoted-printable',
1050 #'Encoding' => '7bit',
1052 'Disposition' => 'inline',
1059 if ( $conf->exists('invoice_email_pdf')
1060 and scalar($conf->config('invoice_email_pdf_note')) ) {
1062 $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
1066 $args{'from'} =~ /\@([\w\.\-]+)/;
1067 my $from = $1 || 'example.com';
1068 my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1071 my $agentnum = $cust_main->agentnum;
1072 if ( defined($args{'template'}) && length($args{'template'})
1073 && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
1076 $logo = 'logo_'. $args{'template'}. '.png';
1080 my $image_data = $conf->config_binary( $logo, $agentnum);
1082 $image = build MIME::Entity
1083 'Type' => 'image/png',
1084 'Encoding' => 'base64',
1085 'Data' => $image_data,
1086 'Filename' => 'logo.png',
1087 'Content-ID' => "<$content_id>",
1090 if ($conf->exists('invoice-barcode')) {
1091 my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1092 $barcode = build MIME::Entity
1093 'Type' => 'image/png',
1094 'Encoding' => 'base64',
1095 'Data' => $self->invoice_barcode(0),
1096 'Filename' => 'barcode.png',
1097 'Content-ID' => "<$barcode_content_id>",
1099 $opt{'barcode_cid'} = $barcode_content_id;
1102 $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
1105 $alternative->attach(
1106 'Type' => 'text/html',
1107 'Encoding' => 'quoted-printable',
1108 'Data' => [ '<html>',
1111 ' '. encode_entities($return{'subject'}),
1114 ' <body bgcolor="#e8e8e8">',
1119 'Disposition' => 'inline',
1120 #'Filename' => 'invoice.pdf',
1124 my @otherparts = ();
1125 if ( $cust_main->email_csv_cdr ) {
1127 push @otherparts, build MIME::Entity
1128 'Type' => 'text/csv',
1129 'Encoding' => '7bit',
1130 'Data' => [ map { "$_\n" }
1131 $self->call_details('prepend_billed_number' => 1)
1133 'Disposition' => 'attachment',
1134 'Filename' => 'usage-'. $self->invnum. '.csv',
1139 if ( $conf->exists('invoice_email_pdf') ) {
1144 # multipart/alternative
1150 my $related = build MIME::Entity 'Type' => 'multipart/related',
1151 'Encoding' => '7bit';
1153 #false laziness w/Misc::send_email
1154 $related->head->replace('Content-type',
1155 $related->mime_type.
1156 '; boundary="'. $related->head->multipart_boundary. '"'.
1157 '; type=multipart/alternative'
1160 $related->add_part($alternative);
1162 $related->add_part($image) if $image;
1164 my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
1166 $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
1170 #no other attachment:
1172 # multipart/alternative
1177 $return{'content-type'} = 'multipart/related';
1178 if ($conf->exists('invoice-barcode') && $barcode) {
1179 $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
1181 $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
1183 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
1184 #$return{'disposition'} = 'inline';
1190 if ( $conf->exists('invoice_email_pdf') ) {
1191 warn "$me creating PDF attachment"
1194 #mime parts arguments a la MIME::Entity->build().
1195 $return{'mimeparts'} = [
1196 { $self->mimebuild_pdf(\%opt) }
1200 if ( $conf->exists('invoice_email_pdf')
1201 and scalar($conf->config('invoice_email_pdf_note')) ) {
1203 warn "$me using 'invoice_email_pdf_note'"
1205 $return{'body'} = [ map { $_ . "\n" }
1206 $conf->config('invoice_email_pdf_note')
1211 warn "$me not using 'invoice_email_pdf_note'"
1213 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1214 $return{'body'} = $args{'print_text'};
1216 $return{'body'} = [ $self->print_text(\%opt) ];
1229 Returns a list suitable for passing to MIME::Entity->build(), representing
1230 this invoice as PDF attachment.
1237 'Type' => 'application/pdf',
1238 'Encoding' => 'base64',
1239 'Data' => [ $self->print_pdf(@_) ],
1240 'Disposition' => 'attachment',
1241 'Filename' => 'invoice-'. $self->invnum. '.pdf',
1245 =item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
1247 Sends this invoice to the destinations configured for this customer: sends
1248 email, prints and/or faxes. See L<FS::cust_main_invoice>.
1250 Options can be passed as a hashref (recommended) or as a list of up to
1251 four values for templatename, agentnum, invoice_from and amount.
1253 I<template>, if specified, is the name of a suffix for alternate invoices.
1255 I<agentnum>, if specified, means that this invoice will only be sent for customers
1256 of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a
1257 single agent) or an arrayref of agentnums.
1259 I<invoice_from>, if specified, overrides the default email invoice From: address.
1261 I<amount>, if specified, only sends the invoice if the total amount owed on this
1262 invoice and all older invoices is greater than the specified amount.
1264 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1268 sub queueable_send {
1271 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1272 or die "invalid invoice number: " . $opt{invnum};
1274 my @args = ( $opt{template}, $opt{agentnum} );
1275 push @args, $opt{invoice_from}
1276 if exists($opt{invoice_from}) && $opt{invoice_from};
1278 my $error = $self->send( @args );
1279 die $error if $error;
1285 my $conf = $self->conf;
1287 my( $template, $invoice_from, $notice_name );
1289 my $balance_over = 0;
1293 $template = $opt->{'template'} || '';
1294 if ( $agentnums = $opt->{'agentnum'} ) {
1295 $agentnums = [ $agentnums ] unless ref($agentnums);
1297 $invoice_from = $opt->{'invoice_from'};
1298 $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
1299 $notice_name = $opt->{'notice_name'};
1301 $template = scalar(@_) ? shift : '';
1302 if ( scalar(@_) && $_[0] ) {
1303 $agentnums = ref($_[0]) ? shift : [ shift ];
1305 $invoice_from = shift if scalar(@_);
1306 $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
1309 my $cust_main = $self->cust_main;
1311 return 'N/A' unless ! $agentnums
1312 or grep { $_ == $cust_main->agentnum } @$agentnums;
1315 unless $cust_main->total_owed_date($self->_date) > $balance_over;
1317 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1318 $conf->config('invoice_from', $cust_main->agentnum );
1321 'template' => $template,
1322 'invoice_from' => $invoice_from,
1323 'notice_name' => ( $notice_name || 'Invoice' ),
1326 my @invoicing_list = $cust_main->invoicing_list;
1328 #$self->email_invoice(\%opt)
1330 if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
1331 && ! $self->invoice_noemail;
1333 #$self->print_invoice(\%opt)
1335 if grep { $_ eq 'POST' } @invoicing_list; #postal
1337 $self->fax_invoice(\%opt)
1338 if grep { $_ eq 'FAX' } @invoicing_list; #fax
1344 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ]
1346 Emails this invoice.
1348 Options can be passed as a hashref (recommended) or as a list of up to
1349 two values for templatename and invoice_from.
1351 I<template>, if specified, is the name of a suffix for alternate invoices.
1353 I<invoice_from>, if specified, overrides the default email invoice From: address.
1355 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1359 sub queueable_email {
1362 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1363 or die "invalid invoice number: " . $opt{invnum};
1365 my %args = ( 'template' => $opt{template} );
1366 $args{$_} = $opt{$_}
1367 foreach grep { exists($opt{$_}) && $opt{$_} }
1368 qw( invoice_from notice_name no_coupon );
1370 my $error = $self->email( \%args );
1371 die $error if $error;
1375 #sub email_invoice {
1378 return if $self->hide;
1379 my $conf = $self->conf;
1381 my( $template, $invoice_from, $notice_name, $no_coupon );
1384 $template = $opt->{'template'} || '';
1385 $invoice_from = $opt->{'invoice_from'};
1386 $notice_name = $opt->{'notice_name'} || 'Invoice';
1387 $no_coupon = $opt->{'no_coupon'} || 0;
1389 $template = scalar(@_) ? shift : '';
1390 $invoice_from = shift if scalar(@_);
1391 $notice_name = 'Invoice';
1395 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1396 $conf->config('invoice_from', $self->cust_main->agentnum );
1398 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
1399 $self->cust_main->invoicing_list;
1401 if ( ! @invoicing_list ) { #no recipients
1402 if ( $conf->exists('cust_bill-no_recipients-error') ) {
1403 die 'No recipients for customer #'. $self->custnum;
1405 #default: better to notify this person than silence
1406 @invoicing_list = ($invoice_from);
1410 my $subject = $self->email_subject($template);
1412 my $error = send_email(
1413 $self->generate_email(
1414 'from' => $invoice_from,
1415 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
1416 'subject' => $subject,
1417 'template' => $template,
1418 'notice_name' => $notice_name,
1419 'no_coupon' => $no_coupon,
1422 die "can't email invoice: $error\n" if $error;
1423 #die "$error\n" if $error;
1429 my $conf = $self->conf;
1431 #my $template = scalar(@_) ? shift : '';
1434 my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
1437 my $cust_main = $self->cust_main;
1438 my $name = $cust_main->name;
1439 my $name_short = $cust_main->name_short;
1440 my $invoice_number = $self->invnum;
1441 my $invoice_date = $self->_date_pretty;
1443 eval qq("$subject");
1446 =item lpr_data HASHREF | [ TEMPLATE ]
1448 Returns the postscript or plaintext for this invoice as an arrayref.
1450 Options can be passed as a hashref (recommended) or as a single optional value
1453 I<template>, if specified, is the name of a suffix for alternate invoices.
1455 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1461 my $conf = $self->conf;
1462 my( $template, $notice_name );
1465 $template = $opt->{'template'} || '';
1466 $notice_name = $opt->{'notice_name'} || 'Invoice';
1468 $template = scalar(@_) ? shift : '';
1469 $notice_name = 'Invoice';
1473 'template' => $template,
1474 'notice_name' => $notice_name,
1477 my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
1478 [ $self->$method( \%opt ) ];
1481 =item print HASHREF | [ TEMPLATE ]
1483 Prints this invoice.
1485 Options can be passed as a hashref (recommended) or as a single optional
1488 I<template>, if specified, is the name of a suffix for alternate invoices.
1490 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1494 #sub print_invoice {
1497 return if $self->hide;
1498 my $conf = $self->conf;
1500 my( $template, $notice_name );
1503 $template = $opt->{'template'} || '';
1504 $notice_name = $opt->{'notice_name'} || 'Invoice';
1506 $template = scalar(@_) ? shift : '';
1507 $notice_name = 'Invoice';
1511 'template' => $template,
1512 'notice_name' => $notice_name,
1515 if($conf->exists('invoice_print_pdf')) {
1516 # Add the invoice to the current batch.
1517 $self->batch_invoice(\%opt);
1520 do_print $self->lpr_data(\%opt);
1524 =item fax_invoice HASHREF | [ TEMPLATE ]
1528 Options can be passed as a hashref (recommended) or as a single optional
1531 I<template>, if specified, is the name of a suffix for alternate invoices.
1533 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1539 return if $self->hide;
1540 my $conf = $self->conf;
1542 my( $template, $notice_name );
1545 $template = $opt->{'template'} || '';
1546 $notice_name = $opt->{'notice_name'} || 'Invoice';
1548 $template = scalar(@_) ? shift : '';
1549 $notice_name = 'Invoice';
1552 die 'FAX invoice destination not (yet?) supported with plain text invoices.'
1553 unless $conf->exists('invoice_latex');
1555 my $dialstring = $self->cust_main->getfield('fax');
1559 'template' => $template,
1560 'notice_name' => $notice_name,
1563 my $error = send_fax( 'docdata' => $self->lpr_data(\%opt),
1564 'dialstring' => $dialstring,
1566 die $error if $error;
1570 =item batch_invoice [ HASHREF ]
1572 Place this invoice into the open batch (see C<FS::bill_batch>). If there
1573 isn't an open batch, one will be created.
1578 my ($self, $opt) = @_;
1579 my $bill_batch = $self->get_open_bill_batch;
1580 my $cust_bill_batch = FS::cust_bill_batch->new({
1581 batchnum => $bill_batch->batchnum,
1582 invnum => $self->invnum,
1584 return $cust_bill_batch->insert($opt);
1587 =item get_open_batch
1589 Returns the currently open batch as an FS::bill_batch object, creating a new
1590 one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
1595 sub get_open_bill_batch {
1597 my $conf = $self->conf;
1598 my $hashref = { status => 'O' };
1599 $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
1600 ? $self->cust_main->agentnum
1602 my $batch = qsearchs('bill_batch', $hashref);
1603 return $batch if $batch;
1604 $batch = FS::bill_batch->new($hashref);
1605 my $error = $batch->insert;
1606 die $error if $error;
1610 =item ftp_invoice [ TEMPLATENAME ]
1612 Sends this invoice data via FTP.
1614 TEMPLATENAME is unused?
1620 my $conf = $self->conf;
1621 my $template = scalar(@_) ? shift : '';
1624 'protocol' => 'ftp',
1625 'server' => $conf->config('cust_bill-ftpserver'),
1626 'username' => $conf->config('cust_bill-ftpusername'),
1627 'password' => $conf->config('cust_bill-ftppassword'),
1628 'dir' => $conf->config('cust_bill-ftpdir'),
1629 'format' => $conf->config('cust_bill-ftpformat'),
1633 =item spool_invoice [ TEMPLATENAME ]
1635 Spools this invoice data (see L<FS::spool_csv>)
1637 TEMPLATENAME is unused?
1643 my $conf = $self->conf;
1644 my $template = scalar(@_) ? shift : '';
1647 'format' => $conf->config('cust_bill-spoolformat'),
1648 'agent_spools' => $conf->exists('cust_bill-spoolagent'),
1652 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
1654 Like B<send>, but only sends the invoice if it is the newest open invoice for
1659 sub send_if_newest {
1664 grep { $_->owed > 0 }
1665 qsearch('cust_bill', {
1666 'custnum' => $self->custnum,
1667 #'_date' => { op=>'>', value=>$self->_date },
1668 'invnum' => { op=>'>', value=>$self->invnum },
1675 =item send_csv OPTION => VALUE, ...
1677 Sends invoice as a CSV data-file to a remote host with the specified protocol.
1681 protocol - currently only "ftp"
1687 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
1688 and YYMMDDHHMMSS is a timestamp.
1690 See L</print_csv> for a description of the output format.
1695 my($self, %opt) = @_;
1699 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1700 mkdir $spooldir, 0700 unless -d $spooldir;
1702 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1703 my $file = "$spooldir/$tracctnum.csv";
1705 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1707 open(CSV, ">$file") or die "can't open $file: $!";
1715 if ( $opt{protocol} eq 'ftp' ) {
1716 eval "use Net::FTP;";
1718 $net = Net::FTP->new($opt{server}) or die @$;
1720 die "unknown protocol: $opt{protocol}";
1723 $net->login( $opt{username}, $opt{password} )
1724 or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
1726 $net->binary or die "can't set binary mode";
1728 $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
1730 $net->put($file) or die "can't put $file: $!";
1740 Spools CSV invoice data.
1746 =item format - any of FS::Misc::::Invoicing::spool_formats
1748 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
1749 customer has the corresponding invoice destinations set (see
1750 L<FS::cust_main_invoice>).
1752 =item agent_spools - if set to a true value, will spool to per-agent files
1753 rather than a single global file
1755 =item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
1756 append to that spool. L<FS::Cron::upload> will then send the spool file to
1759 =item balanceover - if set, only spools the invoice if the total amount owed on
1760 this invoice and all older invoices is greater than the specified amount.
1767 my($self, %opt) = @_;
1769 my $cust_main = $self->cust_main;
1771 if ( $opt{'dest'} ) {
1772 my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
1773 $cust_main->invoicing_list;
1774 return 'N/A' unless $invoicing_list{$opt{'dest'}}
1775 || ! keys %invoicing_list;
1778 if ( $opt{'balanceover'} ) {
1780 if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
1783 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1784 mkdir $spooldir, 0700 unless -d $spooldir;
1786 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1789 if ( $opt{'agent_spools'} ) {
1790 $file = 'agentnum'.$cust_main->agentnum;
1795 if ( $opt{'ftp_targetnum'} ) {
1796 $spooldir .= '/target'.$opt{'ftp_targetnum'};
1797 mkdir $spooldir, 0700 unless -d $spooldir;
1798 } # otherwise it just goes into export.xxx/cust_bill
1800 if ( lc($opt{'format'}) eq 'billco' ) {
1804 $file = "$spooldir/$file.csv";
1806 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1808 open(CSV, ">>$file") or die "can't open $file: $!";
1809 flock(CSV, LOCK_EX);
1814 if ( lc($opt{'format'}) eq 'billco' ) {
1816 flock(CSV, LOCK_UN);
1819 $file =~ s/-header.csv$/-detail.csv/;
1821 open(CSV,">>$file") or die "can't open $file: $!";
1822 flock(CSV, LOCK_EX);
1828 flock(CSV, LOCK_UN);
1835 =item print_csv OPTION => VALUE, ...
1837 Returns CSV data for this invoice.
1841 format - 'default', 'billco', 'oneline', 'bridgestone'
1843 Returns a list consisting of two scalars. The first is a single line of CSV
1844 header information for this invoice. The second is one or more lines of CSV
1845 detail information for this invoice.
1847 If I<format> is not specified or "default", the fields of the CSV file are as
1850 record_type, invnum, custnum, _date, charged, first, last, company, address1,
1851 address2, city, state, zip, country, pkg, setup, recur, sdate, edate
1855 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
1857 B<record_type> is C<cust_bill> for the initial header line only. The
1858 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
1859 fields are filled in.
1861 B<record_type> is C<cust_bill_pkg> for detail lines. Only the first two fields
1862 (B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
1865 =item invnum - invoice number
1867 =item custnum - customer number
1869 =item _date - invoice date
1871 =item charged - total invoice amount
1873 =item first - customer first name
1875 =item last - customer first name
1877 =item company - company name
1879 =item address1 - address line 1
1881 =item address2 - address line 1
1891 =item pkg - line item description
1893 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
1895 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
1897 =item sdate - start date for recurring fee
1899 =item edate - end date for recurring fee
1903 If I<format> is "billco", the fields of the header CSV file are as follows:
1905 +-------------------------------------------------------------------+
1906 | FORMAT HEADER FILE |
1907 |-------------------------------------------------------------------|
1908 | Field | Description | Name | Type | Width |
1909 | 1 | N/A-Leave Empty | RC | CHAR | 2 |
1910 | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 |
1911 | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 |
1912 | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 |
1913 | 5 | Transaction Zip Code | TRZIP | CHAR | 5 |
1914 | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 |
1915 | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 |
1916 | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 |
1917 | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 |
1918 | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 |
1919 | 11 | Transaction City Bill To | TRCITY | CHAR | 20 |
1920 | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 |
1921 | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 |
1922 | 14 | Bill Due Date | DUEDATE | CHAR | 10 |
1923 | 15 | Previous Balance | BALFWD | NUM* | 9 |
1924 | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 |
1925 | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 |
1926 | 18 | Total Amt Due | TOTALDUE | NUM* | 9 |
1927 | 19 | Total Amt Due | AMTDUE | NUM* | 9 |
1928 | 20 | 30 Day Aging | AMT30 | NUM* | 9 |
1929 | 21 | 60 Day Aging | AMT60 | NUM* | 9 |
1930 | 22 | 90 Day Aging | AMT90 | NUM* | 9 |
1931 | 23 | Y/N | AGESWITCH | CHAR | 1 |
1932 | 24 | Remittance automation | SCANLINE | CHAR | 100 |
1933 | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 |
1934 | 26 | Customer Reference Number | CUSTREF | CHAR | 15 |
1935 | 27 | Federal Tax*** | FEDTAX | NUM* | 9 |
1936 | 28 | State Tax*** | STATETAX | NUM* | 9 |
1937 | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 |
1938 +-------+-------------------------------+------------+------+-------+
1940 If I<format> is "billco", the fields of the detail CSV file are as follows:
1942 FORMAT FOR DETAIL FILE
1944 Field | Description | Name | Type | Width
1945 1 | N/A-Leave Empty | RC | CHAR | 2
1946 2 | N/A-Leave Empty | CUSTID | CHAR | 15
1947 3 | Account Number | TRACCTNUM | CHAR | 15
1948 4 | Invoice Number | TRINVOICE | CHAR | 15
1949 5 | Line Sequence (sort order) | LINESEQ | NUM | 6
1950 6 | Transaction Detail | DETAILS | CHAR | 100
1951 7 | Amount | AMT | NUM* | 9
1952 8 | Line Format Control** | LNCTRL | CHAR | 2
1953 9 | Grouping Code | GROUP | CHAR | 2
1954 10 | User Defined | ACCT CODE | CHAR | 15
1956 If format is 'oneline', there is no detail file. Each invoice has a
1957 header line only, with the fields:
1959 Agent number, agent name, customer number, first name, last name, address
1960 line 1, address line 2, city, state, zip, invoice date, invoice number,
1961 amount charged, amount due,
1963 and then, for each line item, three columns containing the package number,
1964 description, and amount.
1966 If format is 'bridgestone', there is no detail file. Each invoice has a
1967 header line with the following fields in a fixed-width format:
1969 Customer number (in display format), date, name (first last), company,
1970 address 1, address 2, city, state, zip.
1972 This is a mailing list format, and has no per-invoice fields. To avoid
1973 sending redundant notices, the spooling event should have a "once" or
1974 "once_percust_every" condition.
1979 my($self, %opt) = @_;
1981 eval "use Text::CSV_XS";
1984 my $cust_main = $self->cust_main;
1986 my $csv = Text::CSV_XS->new({'always_quote'=>1});
1988 if ( lc($opt{'format'}) eq 'billco' ) {
1991 $taxtotal += $_->{'amount'} foreach $self->_items_tax;
1993 my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
1995 my( $previous_balance, @unused ) = $self->previous; #previous balance
1997 my $pmt_cr_applied = 0;
1998 $pmt_cr_applied += $_->{'amount'}
1999 foreach ( $self->_items_payments, $self->_items_credits ) ;
2001 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2004 '', # 1 | N/A-Leave Empty CHAR 2
2005 '', # 2 | N/A-Leave Empty CHAR 15
2006 $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
2007 $self->invnum, # 4 | Transaction Invoice No CHAR 15
2008 $cust_main->zip, # 5 | Transaction Zip Code CHAR 5
2009 $cust_main->company, # 6 | Transaction Company Bill To CHAR 30
2010 #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30
2011 $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30
2012 $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30
2013 $cust_main->address1, # 9 | Bill To Street Address CHAR 30
2014 '', # 10 | Ancillary Billing Information CHAR 30
2015 $cust_main->city, # 11 | Transaction City Bill To CHAR 20
2016 $cust_main->state, # 12 | Transaction State Bill To CHAR 2
2019 time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10
2022 $duedate, # 14 | Bill Due Date CHAR 10
2024 $previous_balance, # 15 | Previous Balance NUM* 9
2025 $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9
2026 sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9
2027 $totaldue, # 18 | Total Amt Due NUM* 9
2028 $totaldue, # 19 | Total Amt Due NUM* 9
2029 '', # 20 | 30 Day Aging NUM* 9
2030 '', # 21 | 60 Day Aging NUM* 9
2031 '', # 22 | 90 Day Aging NUM* 9
2032 'N', # 23 | Y/N CHAR 1
2033 '', # 24 | Remittance automation CHAR 100
2034 $taxtotal, # 25 | Total Taxes & Fees NUM* 9
2035 $self->custnum, # 26 | Customer Reference Number CHAR 15
2036 '0', # 27 | Federal Tax*** NUM* 9
2037 sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9
2038 '0', # 29 | Other Taxes & Fees*** NUM* 9
2041 } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name?
2043 my ($previous_balance) = $self->previous;
2044 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2046 ($_->{pkgnum} || ''),
2049 } $self->_items_pkg;
2052 $cust_main->agentnum,
2053 $cust_main->agent->agent,
2057 $cust_main->address1,
2058 $cust_main->address2,
2064 time2str("%x", $self->_date),
2072 } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
2074 # bypass the CSV stuff and just return this
2075 my $longdate = time2str('%B %d, %Y', time); #current time, right?
2076 my $zip = $cust_main->zip;
2078 my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
2082 "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
2084 $cust_main->display_custnum,
2086 uc(substr($cust_main->contact_firstlast,0,30)),
2087 uc(substr($cust_main->company ,0,30)),
2088 uc(substr($cust_main->address1 ,0,30)),
2089 uc(substr($cust_main->address2 ,0,30)),
2090 uc(substr($cust_main->city ,0,20)),
2091 uc($cust_main->state),
2103 time2str("%x", $self->_date),
2104 sprintf("%.2f", $self->charged),
2105 ( map { $cust_main->getfield($_) }
2106 qw( first last company address1 address2 city state zip country ) ),
2108 ) or die "can't create csv";
2111 my $header = $csv->string. "\n";
2114 if ( lc($opt{'format'}) eq 'billco' ) {
2117 foreach my $item ( $self->_items_pkg ) {
2120 '', # 1 | N/A-Leave Empty CHAR 2
2121 '', # 2 | N/A-Leave Empty CHAR 15
2122 $opt{'tracctnum'}, # 3 | Account Number CHAR 15
2123 $self->invnum, # 4 | Invoice Number CHAR 15
2124 $lineseq++, # 5 | Line Sequence (sort order) NUM 6
2125 $item->{'description'}, # 6 | Transaction Detail CHAR 100
2126 $item->{'amount'}, # 7 | Amount NUM* 9
2127 '', # 8 | Line Format Control** CHAR 2
2128 '', # 9 | Grouping Code CHAR 2
2129 '', # 10 | User Defined CHAR 15
2132 $detail .= $csv->string. "\n";
2136 } elsif ( lc($opt{'format'}) eq 'oneline' ) {
2142 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2144 my($pkg, $setup, $recur, $sdate, $edate);
2145 if ( $cust_bill_pkg->pkgnum ) {
2147 ($pkg, $setup, $recur, $sdate, $edate) = (
2148 $cust_bill_pkg->part_pkg->pkg,
2149 ( $cust_bill_pkg->setup != 0
2150 ? sprintf("%.2f", $cust_bill_pkg->setup )
2152 ( $cust_bill_pkg->recur != 0
2153 ? sprintf("%.2f", $cust_bill_pkg->recur )
2155 ( $cust_bill_pkg->sdate
2156 ? time2str("%x", $cust_bill_pkg->sdate)
2158 ($cust_bill_pkg->edate
2159 ?time2str("%x", $cust_bill_pkg->edate)
2163 } else { #pkgnum tax
2164 next unless $cust_bill_pkg->setup != 0;
2165 $pkg = $cust_bill_pkg->desc;
2166 $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
2167 ( $sdate, $edate ) = ( '', '' );
2173 ( map { '' } (1..11) ),
2174 ($pkg, $setup, $recur, $sdate, $edate)
2175 ) or die "can't create csv";
2177 $detail .= $csv->string. "\n";
2183 ( $header, $detail );
2189 Pays this invoice with a compliemntary payment. If there is an error,
2190 returns the error, otherwise returns false.
2196 my $cust_pay = new FS::cust_pay ( {
2197 'invnum' => $self->invnum,
2198 'paid' => $self->owed,
2201 'payinfo' => $self->cust_main->payinfo,
2209 Attempts to pay this invoice with a credit card payment via a
2210 Business::OnlinePayment realtime gateway. See
2211 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2212 for supported processors.
2218 $self->realtime_bop( 'CC', @_ );
2223 Attempts to pay this invoice with an electronic check (ACH) payment via a
2224 Business::OnlinePayment realtime gateway. See
2225 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2226 for supported processors.
2232 $self->realtime_bop( 'ECHECK', @_ );
2237 Attempts to pay this invoice with phone bill (LEC) payment via a
2238 Business::OnlinePayment realtime gateway. See
2239 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2240 for supported processors.
2246 $self->realtime_bop( 'LEC', @_ );
2250 my( $self, $method ) = (shift,shift);
2251 my $conf = $self->conf;
2254 my $cust_main = $self->cust_main;
2255 my $balance = $cust_main->balance;
2256 my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
2257 $amount = sprintf("%.2f", $amount);
2258 return "not run (balance $balance)" unless $amount > 0;
2260 my $description = 'Internet Services';
2261 if ( $conf->exists('business-onlinepayment-description') ) {
2262 my $dtempl = $conf->config('business-onlinepayment-description');
2264 my $agent_obj = $cust_main->agent
2265 or die "can't retreive agent for $cust_main (agentnum ".
2266 $cust_main->agentnum. ")";
2267 my $agent = $agent_obj->agent;
2268 my $pkgs = join(', ',
2269 map { $_->part_pkg->pkg }
2270 grep { $_->pkgnum } $self->cust_bill_pkg
2272 $description = eval qq("$dtempl");
2275 $cust_main->realtime_bop($method, $amount,
2276 'description' => $description,
2277 'invnum' => $self->invnum,
2278 #this didn't do what we want, it just calls apply_payments_and_credits
2280 'apply_to_invoice' => 1,
2283 #this changes application behavior: auto payments
2284 #triggered against a specific invoice are now applied
2285 #to that invoice instead of oldest open.
2291 =item batch_card OPTION => VALUE...
2293 Adds a payment for this invoice to the pending credit card batch (see
2294 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2295 runs the payment using a realtime gateway.
2300 my ($self, %options) = @_;
2301 my $cust_main = $self->cust_main;
2303 $options{invnum} = $self->invnum;
2305 $cust_main->batch_card(%options);
2308 sub _agent_template {
2310 $self->cust_main->agent_template;
2313 sub _agent_invoice_from {
2315 $self->cust_main->agent_invoice_from;
2318 =item invoice_barcode DIR_OR_FALSE
2320 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
2321 it is taken as the temp directory where the PNG file will be generated and the
2322 PNG file name is returned. Otherwise, the PNG image itself is returned.
2326 sub invoice_barcode {
2327 my ($self, $dir) = (shift,shift);
2329 my $gdbar = new GD::Barcode('Code39',$self->invnum);
2330 die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
2331 my $gd = $gdbar->plot(Height => 30);
2334 my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
2338 ) or die "can't open temp file: $!\n";
2339 print $bh $gd->png or die "cannot write barcode to file: $!\n";
2340 my $png_file = $bh->filename;
2347 =item invnum_date_pretty
2349 Returns a string with the invoice number and date, for example:
2350 "Invoice #54 (3/20/2008)"
2354 sub invnum_date_pretty {
2356 $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
2359 #sub _items_extra_usage_sections {
2361 # my $escape = shift;
2363 # my %sections = ();
2365 # my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
2366 # foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2368 # next unless $cust_bill_pkg->pkgnum > 0;
2370 # foreach my $section ( keys %usage_class ) {
2372 # my $usage = $cust_bill_pkg->usage($section);
2374 # next unless $usage && $usage > 0;
2376 # $sections{$section} ||= 0;
2377 # $sections{$section} += $usage;
2383 # map { { 'description' => &{$escape}($_),
2384 # 'subtotal' => $sections{$_},
2385 # 'summarized' => '',
2386 # 'tax_section' => '',
2389 # sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
2393 sub _items_extra_usage_sections {
2395 my $conf = $self->conf;
2403 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2405 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2406 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2407 next unless $cust_bill_pkg->pkgnum > 0;
2409 foreach my $classnum ( keys %usage_class ) {
2410 my $section = $usage_class{$classnum}->classname;
2411 $classnums{$section} = $classnum;
2413 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
2414 my $amount = $detail->amount;
2415 next unless $amount && $amount > 0;
2417 $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
2418 $sections{$section}{amount} += $amount; #subtotal
2419 $sections{$section}{calls}++;
2420 $sections{$section}{duration} += $detail->duration;
2422 my $desc = $detail->regionname;
2423 my $description = $desc;
2424 $description = substr($desc, 0, $maxlength). '...'
2425 if $format eq 'latex' && length($desc) > $maxlength;
2427 $lines{$section}{$desc} ||= {
2428 description => &{$escape}($description),
2429 #pkgpart => $part_pkg->pkgpart,
2430 pkgnum => $cust_bill_pkg->pkgnum,
2435 #unit_amount => $cust_bill_pkg->unitrecur,
2436 quantity => $cust_bill_pkg->quantity,
2437 product_code => 'N/A',
2438 ext_description => [],
2441 $lines{$section}{$desc}{amount} += $amount;
2442 $lines{$section}{$desc}{calls}++;
2443 $lines{$section}{$desc}{duration} += $detail->duration;
2449 my %sectionmap = ();
2450 foreach (keys %sections) {
2451 my $usage_class = $usage_class{$classnums{$_}};
2452 $sectionmap{$_} = { 'description' => &{$escape}($_),
2453 'amount' => $sections{$_}{amount}, #subtotal
2454 'calls' => $sections{$_}{calls},
2455 'duration' => $sections{$_}{duration},
2457 'tax_section' => '',
2458 'sort_weight' => $usage_class->weight,
2459 ( $usage_class->format
2460 ? ( map { $_ => $usage_class->$_($format) }
2461 qw( description_generator header_generator total_generator total_line_generator )
2468 my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
2472 foreach my $section ( keys %lines ) {
2473 foreach my $line ( keys %{$lines{$section}} ) {
2474 my $l = $lines{$section}{$line};
2475 $l->{section} = $sectionmap{$section};
2476 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2477 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2482 return(\@sections, \@lines);
2488 my $end = $self->_date;
2490 # start at date of previous invoice + 1 second or 0 if no previous invoice
2491 my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
2492 $start = 0 if !$start;
2495 my $cust_main = $self->cust_main;
2496 my @pkgs = $cust_main->all_pkgs;
2497 my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
2500 foreach my $pkg ( @pkgs ) {
2501 my @h_cust_svc = $pkg->h_cust_svc($end);
2502 foreach my $h_cust_svc ( @h_cust_svc ) {
2503 next if grep {$_ eq $h_cust_svc->svcnum} @seen;
2504 next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
2506 my $inserted = $h_cust_svc->date_inserted;
2507 my $deleted = $h_cust_svc->date_deleted;
2508 my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
2510 $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted;
2512 # DID either activated or ported in; cannot be both for same DID simultaneously
2513 if ($inserted >= $start && $inserted <= $end && $phone_inserted
2514 && (!$phone_inserted->lnp_status
2515 || $phone_inserted->lnp_status eq ''
2516 || $phone_inserted->lnp_status eq 'native')) {
2519 else { # this one not so clean, should probably move to (h_)svc_phone
2520 my $phone_portedin = qsearchs( 'h_svc_phone',
2521 { 'svcnum' => $h_cust_svc->svcnum,
2522 'lnp_status' => 'portedin' },
2523 FS::h_svc_phone->sql_h_searchs($end),
2525 $num_portedin++ if $phone_portedin;
2528 # DID either deactivated or ported out; cannot be both for same DID simultaneously
2529 if($deleted >= $start && $deleted <= $end && $phone_deleted
2530 && (!$phone_deleted->lnp_status
2531 || $phone_deleted->lnp_status ne 'portingout')) {
2534 elsif($deleted >= $start && $deleted <= $end && $phone_deleted
2535 && $phone_deleted->lnp_status
2536 && $phone_deleted->lnp_status eq 'portingout') {
2540 # increment usage minutes
2541 if ( $phone_inserted ) {
2542 my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
2543 $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
2546 warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
2549 # don't look at this service again
2550 push @seen, $h_cust_svc->svcnum;
2554 $minutes = sprintf("%d", $minutes);
2555 ("Activated: $num_activated Ported-In: $num_portedin Deactivated: "
2556 . "$num_deactivated Ported-Out: $num_portedout ",
2557 "Total Minutes: $minutes");
2560 sub _items_accountcode_cdr {
2565 my $section = { 'amount' => 0,
2568 'sort_weight' => '',
2570 'description' => 'Usage by Account Code',
2576 my %accountcodes = ();
2578 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2579 next unless $cust_bill_pkg->pkgnum > 0;
2581 my @header = $cust_bill_pkg->details_header;
2582 next unless scalar(@header);
2583 $section->{'header'} = join(',',@header);
2585 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2587 $section->{'header'} = $detail->formatted('format' => $format)
2588 if($detail->detail eq $section->{'header'});
2590 my $accountcode = $detail->accountcode;
2591 next unless $accountcode;
2593 my $amount = $detail->amount;
2594 next unless $amount && $amount > 0;
2596 $accountcodes{$accountcode} ||= {
2597 description => $accountcode,
2604 product_code => 'N/A',
2605 section => $section,
2606 ext_description => [ $section->{'header'} ],
2610 $section->{'amount'} += $amount;
2611 $accountcodes{$accountcode}{'amount'} += $amount;
2612 $accountcodes{$accountcode}{calls}++;
2613 $accountcodes{$accountcode}{duration} += $detail->duration;
2614 push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
2618 foreach my $l ( values %accountcodes ) {
2619 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2620 my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
2621 foreach my $sorted_detail ( @sorted_detail ) {
2622 push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
2624 delete $l->{detail_temp};
2628 my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
2630 return ($section,\@sorted_lines);
2633 sub _items_svc_phone_sections {
2635 my $conf = $self->conf;
2643 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2645 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2646 $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
2648 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2649 next unless $cust_bill_pkg->pkgnum > 0;
2651 my @header = $cust_bill_pkg->details_header;
2652 next unless scalar(@header);
2654 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2656 my $phonenum = $detail->phonenum;
2657 next unless $phonenum;
2659 my $amount = $detail->amount;
2660 next unless $amount && $amount > 0;
2662 $sections{$phonenum} ||= { 'amount' => 0,
2665 'sort_weight' => -1,
2666 'phonenum' => $phonenum,
2668 $sections{$phonenum}{amount} += $amount; #subtotal
2669 $sections{$phonenum}{calls}++;
2670 $sections{$phonenum}{duration} += $detail->duration;
2672 my $desc = $detail->regionname;
2673 my $description = $desc;
2674 $description = substr($desc, 0, $maxlength). '...'
2675 if $format eq 'latex' && length($desc) > $maxlength;
2677 $lines{$phonenum}{$desc} ||= {
2678 description => &{$escape}($description),
2679 #pkgpart => $part_pkg->pkgpart,
2687 product_code => 'N/A',
2688 ext_description => [],
2691 $lines{$phonenum}{$desc}{amount} += $amount;
2692 $lines{$phonenum}{$desc}{calls}++;
2693 $lines{$phonenum}{$desc}{duration} += $detail->duration;
2695 my $line = $usage_class{$detail->classnum}->classname;
2696 $sections{"$phonenum $line"} ||=
2700 'sort_weight' => $usage_class{$detail->classnum}->weight,
2701 'phonenum' => $phonenum,
2702 'header' => [ @header ],
2704 $sections{"$phonenum $line"}{amount} += $amount; #subtotal
2705 $sections{"$phonenum $line"}{calls}++;
2706 $sections{"$phonenum $line"}{duration} += $detail->duration;
2708 $lines{"$phonenum $line"}{$desc} ||= {
2709 description => &{$escape}($description),
2710 #pkgpart => $part_pkg->pkgpart,
2718 product_code => 'N/A',
2719 ext_description => [],
2722 $lines{"$phonenum $line"}{$desc}{amount} += $amount;
2723 $lines{"$phonenum $line"}{$desc}{calls}++;
2724 $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
2725 push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
2726 $detail->formatted('format' => $format);
2731 my %sectionmap = ();
2732 my $simple = new FS::usage_class { format => 'simple' }; #bleh
2733 foreach ( keys %sections ) {
2734 my @header = @{ $sections{$_}{header} || [] };
2736 new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
2737 my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
2738 my $usage_class = $summary ? $simple : $usage_simple;
2739 my $ending = $summary ? ' usage charges' : '';
2742 $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
2744 $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
2745 'amount' => $sections{$_}{amount}, #subtotal
2746 'calls' => $sections{$_}{calls},
2747 'duration' => $sections{$_}{duration},
2749 'tax_section' => '',
2750 'phonenum' => $sections{$_}{phonenum},
2751 'sort_weight' => $sections{$_}{sort_weight},
2752 'post_total' => $summary, #inspire pagebreak
2754 ( map { $_ => $usage_class->$_($format, %gen_opt) }
2755 qw( description_generator
2758 total_line_generator
2765 my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
2766 $a->{sort_weight} <=> $b->{sort_weight}
2771 foreach my $section ( keys %lines ) {
2772 foreach my $line ( keys %{$lines{$section}} ) {
2773 my $l = $lines{$section}{$line};
2774 $l->{section} = $sectionmap{$section};
2775 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2776 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2781 if($conf->exists('phone_usage_class_summary')) {
2782 # this only works with Latex
2786 # after this, we'll have only two sections per DID:
2787 # Calls Summary and Calls Detail
2788 foreach my $section ( @sections ) {
2789 if($section->{'post_total'}) {
2790 $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
2791 $section->{'total_line_generator'} = sub { '' };
2792 $section->{'total_generator'} = sub { '' };
2793 $section->{'header_generator'} = sub { '' };
2794 $section->{'description_generator'} = '';
2795 push @newsections, $section;
2796 my %calls_detail = %$section;
2797 $calls_detail{'post_total'} = '';
2798 $calls_detail{'sort_weight'} = '';
2799 $calls_detail{'description_generator'} = sub { '' };
2800 $calls_detail{'header_generator'} = sub {
2801 return ' & Date/Time & Called Number & Duration & Price'
2802 if $format eq 'latex';
2805 $calls_detail{'description'} = 'Calls Detail: '
2806 . $section->{'phonenum'};
2807 push @newsections, \%calls_detail;
2811 # after this, each usage class is collapsed/summarized into a single
2812 # line under the Calls Summary section
2813 foreach my $newsection ( @newsections ) {
2814 if($newsection->{'post_total'}) { # this means Calls Summary
2815 foreach my $section ( @sections ) {
2816 next unless ($section->{'phonenum'} eq $newsection->{'phonenum'}
2817 && !$section->{'post_total'});
2818 my $newdesc = $section->{'description'};
2819 my $tn = $section->{'phonenum'};
2820 $newdesc =~ s/$tn//g;
2821 my $line = { ext_description => [],
2825 calls => $section->{'calls'},
2826 section => $newsection,
2827 duration => $section->{'duration'},
2828 description => $newdesc,
2829 amount => sprintf("%.2f",$section->{'amount'}),
2830 product_code => 'N/A',
2832 push @newlines, $line;
2837 # after this, Calls Details is populated with all CDRs
2838 foreach my $newsection ( @newsections ) {
2839 if(!$newsection->{'post_total'}) { # this means Calls Details
2840 foreach my $line ( @lines ) {
2841 next unless (scalar(@{$line->{'ext_description'}}) &&
2842 $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
2844 my @extdesc = @{$line->{'ext_description'}};
2846 foreach my $extdesc ( @extdesc ) {
2847 $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
2848 push @newextdesc, $extdesc;
2850 $line->{'ext_description'} = \@newextdesc;
2851 $line->{'section'} = $newsection;
2852 push @newlines, $line;
2857 return(\@newsections, \@newlines);
2860 return(\@sections, \@lines);
2864 sub _items_previous {
2866 my $conf = $self->conf;
2867 my $cust_main = $self->cust_main;
2868 my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
2870 foreach ( @pr_cust_bill ) {
2871 my $date = $conf->exists('invoice_show_prior_due_date')
2872 ? 'due '. $_->due_date2str($date_format)
2873 : time2str($date_format, $_->_date);
2875 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
2876 #'pkgpart' => 'N/A',
2878 'amount' => sprintf("%.2f", $_->owed),
2884 # 'description' => 'Previous Balance',
2885 # #'pkgpart' => 'N/A',
2886 # 'pkgnum' => 'N/A',
2887 # 'amount' => sprintf("%10.2f", $pr_total ),
2888 # 'ext_description' => [ map {
2889 # "Invoice ". $_->invnum.
2890 # " (". time2str("%x",$_->_date). ") ".
2891 # sprintf("%10.2f", $_->owed)
2892 # } @pr_cust_bill ],
2897 sub _items_credits {
2898 my( $self, %opt ) = @_;
2899 my $trim_len = $opt{'trim_len'} || 60;
2903 foreach ( $self->cust_credited ) {
2905 #something more elaborate if $_->amount ne $_->cust_credit->credited ?
2907 my $reason = substr($_->cust_credit->reason, 0, $trim_len);
2908 $reason .= '...' if length($reason) < length($_->cust_credit->reason);
2909 $reason = " ($reason) " if $reason;
2912 #'description' => 'Credit ref\#'. $_->crednum.
2913 # " (". time2str("%x",$_->cust_credit->_date) .")".
2915 'description' => $self->mt('Credit applied').' '.
2916 time2str($date_format,$_->cust_credit->_date). $reason,
2917 'amount' => sprintf("%.2f",$_->amount),
2925 sub _items_payments {
2929 #get & print payments
2930 foreach ( $self->cust_bill_pay ) {
2932 #something more elaborate if $_->amount ne ->cust_pay->paid ?
2935 'description' => $self->mt('Payment received').' '.
2936 time2str($date_format,$_->cust_pay->_date ),
2937 'amount' => sprintf("%.2f", $_->amount )
2945 =item call_details [ OPTION => VALUE ... ]
2947 Returns an array of CSV strings representing the call details for this invoice
2948 The only option available is the boolean prepend_billed_number
2953 my ($self, %opt) = @_;
2955 my $format_function = sub { shift };
2957 if ($opt{prepend_billed_number}) {
2958 $format_function = sub {
2962 $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
2967 my @details = map { $_->details( 'format_function' => $format_function,
2968 'escape_function' => sub{ return() },
2972 $self->cust_bill_pkg;
2973 my $header = $details[0];
2974 ( $header, grep { $_ ne $header } @details );
2984 =item process_reprint
2988 sub process_reprint {
2989 process_re_X('print', @_);
2992 =item process_reemail
2996 sub process_reemail {
2997 process_re_X('email', @_);
3005 process_re_X('fax', @_);
3013 process_re_X('ftp', @_);
3020 sub process_respool {
3021 process_re_X('spool', @_);
3024 use Storable qw(thaw);
3028 my( $method, $job ) = ( shift, shift );
3029 warn "$me process_re_X $method for job $job\n" if $DEBUG;
3031 my $param = thaw(decode_base64(shift));
3032 warn Dumper($param) if $DEBUG;
3043 # spool_invoice ftp_invoice fax_invoice print_invoice
3044 my($method, $job, %param ) = @_;
3046 warn "re_X $method for job $job with param:\n".
3047 join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
3050 #some false laziness w/search/cust_bill.html
3052 my $orderby = 'ORDER BY cust_bill._date';
3054 my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
3056 my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
3058 my @cust_bill = qsearch( {
3059 #'select' => "cust_bill.*",
3060 'table' => 'cust_bill',
3061 'addl_from' => $addl_from,
3063 'extra_sql' => $extra_sql,
3064 'order_by' => $orderby,
3068 $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
3070 warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
3073 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
3074 foreach my $cust_bill ( @cust_bill ) {
3075 $cust_bill->$method();
3077 if ( $job ) { #progressbar foo
3079 if ( time - $min_sec > $last ) {
3080 my $error = $job->update_statustext(
3081 int( 100 * $num / scalar(@cust_bill) )
3083 die $error if $error;
3094 =head1 CLASS METHODS
3100 Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
3105 my ($class, $start, $end) = @_;
3107 $class->paid_sql($start, $end). ' - '.
3108 $class->credited_sql($start, $end);
3113 Returns an SQL fragment to retreive the net amount (charged minus credited).
3118 my ($class, $start, $end) = @_;
3119 'charged - '. $class->credited_sql($start, $end);
3124 Returns an SQL fragment to retreive the amount paid against this invoice.
3129 my ($class, $start, $end) = @_;
3130 $start &&= "AND cust_bill_pay._date <= $start";
3131 $end &&= "AND cust_bill_pay._date > $end";
3132 $start = '' unless defined($start);
3133 $end = '' unless defined($end);
3134 "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
3135 WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )";
3140 Returns an SQL fragment to retreive the amount credited against this invoice.
3145 my ($class, $start, $end) = @_;
3146 $start &&= "AND cust_credit_bill._date <= $start";
3147 $end &&= "AND cust_credit_bill._date > $end";
3148 $start = '' unless defined($start);
3149 $end = '' unless defined($end);
3150 "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
3151 WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
3156 Returns an SQL fragment to retrieve the due date of an invoice.
3157 Currently only supported on PostgreSQL.
3162 my $conf = new FS::Conf;
3166 cust_bill.invoice_terms,
3167 cust_main.invoice_terms,
3168 \''.($conf->config('invoice_default_terms') || '').'\'
3169 ), E\'Net (\\\\d+)\'
3171 ) * 86400 + cust_bill._date'
3174 =item search_sql_where HASHREF
3176 Class method which returns an SQL WHERE fragment to search for parameters
3177 specified in HASHREF. Valid parameters are
3183 List reference of start date, end date, as UNIX timestamps.
3193 List reference of charged limits (exclusive).
3197 List reference of charged limits (exclusive).
3201 flag, return open invoices only
3205 flag, return net invoices only
3209 =item newest_percust
3213 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
3217 sub search_sql_where {
3218 my($class, $param) = @_;
3220 warn "$me search_sql_where called with params: \n".
3221 join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
3227 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
3228 push @search, "cust_main.agentnum = $1";
3232 if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
3233 push @search, "cust_main.refnum = $1";
3237 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
3238 push @search, "cust_bill.custnum = $1";
3242 if ( $param->{_date} ) {
3243 my($beginning, $ending) = @{$param->{_date}};
3245 push @search, "cust_bill._date >= $beginning",
3246 "cust_bill._date < $ending";
3250 if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
3251 push @search, "cust_bill.invnum >= $1";
3253 if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
3254 push @search, "cust_bill.invnum <= $1";
3258 if ( $param->{charged} ) {
3259 my @charged = ref($param->{charged})
3260 ? @{ $param->{charged} }
3261 : ($param->{charged});
3263 push @search, map { s/^charged/cust_bill.charged/; $_; }
3267 my $owed_sql = FS::cust_bill->owed_sql;
3270 if ( $param->{owed} ) {
3271 my @owed = ref($param->{owed})
3272 ? @{ $param->{owed} }
3274 push @search, map { s/^owed/$owed_sql/; $_; }
3279 push @search, "0 != $owed_sql"
3280 if $param->{'open'};
3281 push @search, '0 != '. FS::cust_bill->net_sql
3285 push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
3286 if $param->{'days'};
3289 if ( $param->{'newest_percust'} ) {
3291 #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
3292 #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
3294 my @newest_where = map { my $x = $_;
3295 $x =~ s/\bcust_bill\./newest_cust_bill./g;
3298 grep ! /^cust_main./, @search;
3299 my $newest_where = scalar(@newest_where)
3300 ? ' AND '. join(' AND ', @newest_where)
3304 push @search, "cust_bill._date = (
3305 SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
3306 WHERE newest_cust_bill.custnum = cust_bill.custnum
3312 #promised_date - also has an option to accept nulls
3313 if ( $param->{promised_date} ) {
3314 my($beginning, $ending, $null) = @{$param->{promised_date}};
3316 push @search, "(( cust_bill.promised_date >= $beginning AND ".
3317 "cust_bill.promised_date < $ending )" .
3318 ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
3321 #agent virtualization
3322 my $curuser = $FS::CurrentUser::CurrentUser;
3323 if ( $curuser->username eq 'fs_queue'
3324 && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
3326 my $newuser = qsearchs('access_user', {
3327 'username' => $username,
3331 $curuser = $newuser;
3333 warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
3336 push @search, $curuser->agentnums_sql;
3338 join(' AND ', @search );
3350 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
3351 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base