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;
41 use FS::cust_bill_void;
45 $me = '[FS::cust_bill]';
47 #ask FS::UID to run this stuff for us later
48 FS::UID->install_callback( sub {
49 my $conf = new FS::Conf; #global
50 $date_format = $conf->config('date_format') || '%x'; #/YY
55 FS::cust_bill - Object methods for cust_bill records
61 $record = new FS::cust_bill \%hash;
62 $record = new FS::cust_bill { 'column' => 'value' };
64 $error = $record->insert;
66 $error = $new_record->replace($old_record);
68 $error = $record->delete;
70 $error = $record->check;
72 ( $total_previous_balance, @previous_cust_bill ) = $record->previous;
74 @cust_bill_pkg_objects = $cust_bill->cust_bill_pkg;
76 ( $total_previous_credits, @previous_cust_credit ) = $record->cust_credit;
78 @cust_pay_objects = $cust_bill->cust_pay;
80 $tax_amount = $record->tax;
82 @lines = $cust_bill->print_text;
83 @lines = $cust_bill->print_text $time;
87 An FS::cust_bill object represents an invoice; a declaration that a customer
88 owes you money. The specific charges are itemized as B<cust_bill_pkg> records
89 (see L<FS::cust_bill_pkg>). FS::cust_bill inherits from FS::Record. The
90 following fields are currently supported:
96 =item invnum - primary key (assigned automatically for new invoices)
98 =item custnum - customer (see L<FS::cust_main>)
100 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
101 L<Time::Local> and L<Date::Parse> for conversion functions.
103 =item charged - amount of this invoice
105 =item invoice_terms - optional terms override for this specific invoice
109 Customer info at invoice generation time
113 =item previous_balance
115 =item billing_balance
123 =item printed - deprecated
131 =item closed - books closed flag, empty or `Y'
133 =item statementnum - invoice aggregation (see L<FS::cust_statement>)
135 =item agent_invid - legacy invoice number
137 =item promised_date - customer promised payment date, for collection
147 Creates a new invoice. To add the invoice to the database, see L<"insert">.
148 Invoices are normally created by calling the bill method of a customer object
149 (see L<FS::cust_main>).
153 sub table { 'cust_bill'; }
154 sub notice_name { 'Invoice'; }
156 sub cust_linked { $_[0]->cust_main_custnum; }
157 sub cust_unlinked_msg {
159 "WARNING: can't find cust_main.custnum ". $self->custnum.
160 ' (cust_bill.invnum '. $self->invnum. ')';
165 Adds this invoice to the database ("Posts" the invoice). If there is an error,
166 returns the error, otherwise returns false.
172 warn "$me insert called\n" if $DEBUG;
174 local $SIG{HUP} = 'IGNORE';
175 local $SIG{INT} = 'IGNORE';
176 local $SIG{QUIT} = 'IGNORE';
177 local $SIG{TERM} = 'IGNORE';
178 local $SIG{TSTP} = 'IGNORE';
179 local $SIG{PIPE} = 'IGNORE';
181 my $oldAutoCommit = $FS::UID::AutoCommit;
182 local $FS::UID::AutoCommit = 0;
185 my $error = $self->SUPER::insert;
187 $dbh->rollback if $oldAutoCommit;
191 if ( $self->get('cust_bill_pkg') ) {
192 foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
193 $cust_bill_pkg->invnum($self->invnum);
194 my $error = $cust_bill_pkg->insert;
196 $dbh->rollback if $oldAutoCommit;
197 return "can't create invoice line item: $error";
202 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
209 Voids this invoice: deletes the invoice and adds a record of the voided invoice
210 to the FS::cust_bill_void table (and related tables starting from
211 FS::cust_bill_pkg_void).
217 my $reason = scalar(@_) ? shift : '';
219 local $SIG{HUP} = 'IGNORE';
220 local $SIG{INT} = 'IGNORE';
221 local $SIG{QUIT} = 'IGNORE';
222 local $SIG{TERM} = 'IGNORE';
223 local $SIG{TSTP} = 'IGNORE';
224 local $SIG{PIPE} = 'IGNORE';
226 my $oldAutoCommit = $FS::UID::AutoCommit;
227 local $FS::UID::AutoCommit = 0;
230 my $cust_bill_void = new FS::cust_bill_void ( {
231 map { $_ => $self->get($_) } $self->fields
233 $cust_bill_void->reason($reason);
234 my $error = $cust_bill_void->insert;
236 $dbh->rollback if $oldAutoCommit;
240 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
241 my $error = $cust_bill_pkg->void($reason);
243 $dbh->rollback if $oldAutoCommit;
248 $error = $self->delete;
250 $dbh->rollback if $oldAutoCommit;
254 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
262 This method now works but you probably shouldn't use it. Instead, apply a
263 credit against the invoice, or use the new void method.
265 Using this method to delete invoices outright is really, really bad. There
266 would be no record you ever posted this invoice, and there are no check to
267 make sure charged = 0 or that there are no associated cust_bill_pkg records.
269 Really, don't use it.
275 return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
277 local $SIG{HUP} = 'IGNORE';
278 local $SIG{INT} = 'IGNORE';
279 local $SIG{QUIT} = 'IGNORE';
280 local $SIG{TERM} = 'IGNORE';
281 local $SIG{TSTP} = 'IGNORE';
282 local $SIG{PIPE} = 'IGNORE';
284 my $oldAutoCommit = $FS::UID::AutoCommit;
285 local $FS::UID::AutoCommit = 0;
288 foreach my $table (qw(
299 foreach my $linked ( $self->$table() ) {
300 my $error = $linked->delete;
302 $dbh->rollback if $oldAutoCommit;
309 my $error = $self->SUPER::delete(@_);
311 $dbh->rollback if $oldAutoCommit;
315 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
321 =item replace [ OLD_RECORD ]
323 You can, but probably shouldn't modify invoices...
325 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
326 supplied, replaces this record. If there is an error, returns the error,
327 otherwise returns false.
331 #replace can be inherited from Record.pm
333 # replace_check is now the preferred way to #implement replace data checks
334 # (so $object->replace() works without an argument)
337 my( $new, $old ) = ( shift, shift );
338 return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
339 #return "Can't change _date!" unless $old->_date eq $new->_date;
340 return "Can't change _date" unless $old->_date == $new->_date;
341 return "Can't change charged" unless $old->charged == $new->charged
342 || $old->charged == 0
343 || $new->{'Hash'}{'cc_surcharge_replace_hack'};
349 =item add_cc_surcharge
355 sub add_cc_surcharge {
356 my ($self, $pkgnum, $amount) = (shift, shift, shift);
359 my $cust_bill_pkg = new FS::cust_bill_pkg({
360 'invnum' => $self->invnum,
364 $error = $cust_bill_pkg->insert;
365 return $error if $error;
367 $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
368 $self->charged($self->charged+$amount);
369 $error = $self->replace;
370 return $error if $error;
372 $self->apply_payments_and_credits;
378 Checks all fields to make sure this is a valid invoice. If there is an error,
379 returns the error, otherwise returns false. Called by the insert and replace
388 $self->ut_numbern('invnum')
389 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
390 || $self->ut_numbern('_date')
391 || $self->ut_money('charged')
392 || $self->ut_numbern('printed')
393 || $self->ut_enum('closed', [ '', 'Y' ])
394 || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
395 || $self->ut_numbern('agent_invid') #varchar?
397 return $error if $error;
399 $self->_date(time) unless $self->_date;
401 $self->printed(0) if $self->printed eq '';
408 Returns the displayed invoice number for this invoice: agent_invid if
409 cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
415 my $conf = $self->conf;
416 if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
417 return $self->agent_invid;
419 return $self->invnum;
425 Returns a list consisting of the total previous balance for this customer,
426 followed by the previous outstanding invoices (as FS::cust_bill objects also).
433 my @cust_bill = sort { $a->_date <=> $b->_date }
434 grep { $_->owed != 0 }
435 qsearch( 'cust_bill', { 'custnum' => $self->custnum,
436 '_date' => { op=>'<', value=>$self->_date },
439 foreach ( @cust_bill ) { $total += $_->owed; }
443 =item enable_previous
445 Whether to show the 'Previous Charges' section when printing this invoice.
446 The negation of the 'disable_previous_balance' config setting.
450 sub enable_previous {
452 my $agentnum = $self->cust_main->agentnum;
453 !$self->conf->exists('disable_previous_balance', $agentnum);
458 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
465 { 'table' => 'cust_bill_pkg',
466 'hashref' => { 'invnum' => $self->invnum },
467 'order_by' => 'ORDER BY billpkgnum',
472 =item cust_bill_pkg_pkgnum PKGNUM
474 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
479 sub cust_bill_pkg_pkgnum {
480 my( $self, $pkgnum ) = @_;
482 { 'table' => 'cust_bill_pkg',
483 'hashref' => { 'invnum' => $self->invnum,
486 'order_by' => 'ORDER BY billpkgnum',
493 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
500 my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
501 $self->cust_bill_pkg;
503 grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
508 Returns true if any of the packages (or their definitions) corresponding to the
509 line items for this invoice have the no_auto flag set.
515 grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
518 =item open_cust_bill_pkg
520 Returns the open line items for this invoice.
522 Note that cust_bill_pkg with both setup and recur fees are returned as two
523 separate line items, each with only one fee.
527 # modeled after cust_main::open_cust_bill
528 sub open_cust_bill_pkg {
531 # grep { $_->owed > 0 } $self->cust_bill_pkg
533 my %other = ( 'recur' => 'setup',
534 'setup' => 'recur', );
536 foreach my $field ( qw( recur setup )) {
537 push @open, map { $_->set( $other{$field}, 0 ); $_; }
538 grep { $_->owed($field) > 0 }
539 $self->cust_bill_pkg;
545 =item cust_bill_event
547 Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
551 sub cust_bill_event {
553 qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
556 =item num_cust_bill_event
558 Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
562 sub num_cust_bill_event {
565 "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
566 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
567 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
568 $sth->fetchrow_arrayref->[0];
573 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
577 #false laziness w/cust_pkg.pm
581 'table' => 'cust_event',
582 'addl_from' => 'JOIN part_event USING ( eventpart )',
583 'hashref' => { 'tablenum' => $self->invnum },
584 'extra_sql' => " AND eventtable = 'cust_bill' ",
590 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
594 #false laziness w/cust_pkg.pm
598 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
599 " WHERE tablenum = ? AND eventtable = 'cust_bill'";
600 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
601 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
602 $sth->fetchrow_arrayref->[0];
607 Returns the customer (see L<FS::cust_main>) for this invoice.
613 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
616 =item cust_suspend_if_balance_over AMOUNT
618 Suspends the customer associated with this invoice if the total amount owed on
619 this invoice and all older invoices is greater than the specified amount.
621 Returns a list: an empty list on success or a list of errors.
625 sub cust_suspend_if_balance_over {
626 my( $self, $amount ) = ( shift, shift );
627 my $cust_main = $self->cust_main;
628 if ( $cust_main->total_owed_date($self->_date) < $amount ) {
631 $cust_main->suspend(@_);
637 Depreciated. See the cust_credited method.
639 #Returns a list consisting of the total previous credited (see
640 #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
641 #outstanding credits (FS::cust_credit objects).
647 croak "FS::cust_bill->cust_credit depreciated; see ".
648 "FS::cust_bill->cust_credit_bill";
651 #my @cust_credit = sort { $a->_date <=> $b->_date }
652 # grep { $_->credited != 0 && $_->_date < $self->_date }
653 # qsearch('cust_credit', { 'custnum' => $self->custnum } )
655 #foreach (@cust_credit) { $total += $_->credited; }
656 #$total, @cust_credit;
661 Depreciated. See the cust_bill_pay method.
663 #Returns all payments (see L<FS::cust_pay>) for this invoice.
669 croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
671 #sort { $a->_date <=> $b->_date }
672 # qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
678 qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
681 sub cust_bill_pay_batch {
683 qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
688 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
694 map { $_ } #return $self->num_cust_bill_pay unless wantarray;
695 sort { $a->_date <=> $b->_date }
696 qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
701 =item cust_credit_bill
703 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
709 map { $_ } #return $self->num_cust_credit_bill unless wantarray;
710 sort { $a->_date <=> $b->_date }
711 qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
715 sub cust_credit_bill {
716 shift->cust_credited(@_);
719 #=item cust_bill_pay_pkgnum PKGNUM
721 #Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
722 #with matching pkgnum.
726 #sub cust_bill_pay_pkgnum {
727 # my( $self, $pkgnum ) = @_;
728 # map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
729 # sort { $a->_date <=> $b->_date }
730 # qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
731 # 'pkgnum' => $pkgnum,
736 =item cust_bill_pay_pkg PKGNUM
738 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
739 applied against the matching pkgnum.
743 sub cust_bill_pay_pkg {
744 my( $self, $pkgnum ) = @_;
747 'select' => 'cust_bill_pay_pkg.*',
748 'table' => 'cust_bill_pay_pkg',
749 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
750 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
751 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
752 " AND cust_bill_pkg.pkgnum = $pkgnum",
757 #=item cust_credited_pkgnum PKGNUM
759 #=item cust_credit_bill_pkgnum PKGNUM
761 #Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
762 #with matching pkgnum.
766 #sub cust_credited_pkgnum {
767 # my( $self, $pkgnum ) = @_;
768 # map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
769 # sort { $a->_date <=> $b->_date }
770 # qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
771 # 'pkgnum' => $pkgnum,
776 #sub cust_credit_bill_pkgnum {
777 # shift->cust_credited_pkgnum(@_);
780 =item cust_credit_bill_pkg PKGNUM
782 Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
783 applied against the matching pkgnum.
787 sub cust_credit_bill_pkg {
788 my( $self, $pkgnum ) = @_;
791 'select' => 'cust_credit_bill_pkg.*',
792 'table' => 'cust_credit_bill_pkg',
793 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
794 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
795 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
796 " AND cust_bill_pkg.pkgnum = $pkgnum",
801 =item cust_bill_batch
803 Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
807 sub cust_bill_batch {
809 qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
814 Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
815 hash keyed by term length.
821 FS::discount_plan->all($self);
826 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
833 my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
835 foreach (@taxlines) { $total += $_->setup; }
841 Returns the amount owed (still outstanding) on this invoice, which is charged
842 minus all payment applications (see L<FS::cust_bill_pay>) and credit
843 applications (see L<FS::cust_credit_bill>).
849 my $balance = $self->charged;
850 $balance -= $_->amount foreach ( $self->cust_bill_pay );
851 $balance -= $_->amount foreach ( $self->cust_credited );
852 $balance = sprintf( "%.2f", $balance);
853 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
858 my( $self, $pkgnum ) = @_;
860 #my $balance = $self->charged;
862 $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
864 $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum);
865 $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum);
867 $balance = sprintf( "%.2f", $balance);
868 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
874 Returns true if this invoice should be hidden. See the
875 selfservice-hide_invoices-taxclass configuraiton setting.
881 my $conf = $self->conf;
882 my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
884 my @cust_bill_pkg = $self->cust_bill_pkg;
885 my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
886 ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
889 =item apply_payments_and_credits [ OPTION => VALUE ... ]
891 Applies unapplied payments and credits to this invoice.
893 A hash of optional arguments may be passed. Currently "manual" is supported.
894 If true, a payment receipt is sent instead of a statement when
895 'payment_receipt_email' configuration option is set.
897 If there is an error, returns the error, otherwise returns false.
901 sub apply_payments_and_credits {
902 my( $self, %options ) = @_;
903 my $conf = $self->conf;
905 local $SIG{HUP} = 'IGNORE';
906 local $SIG{INT} = 'IGNORE';
907 local $SIG{QUIT} = 'IGNORE';
908 local $SIG{TERM} = 'IGNORE';
909 local $SIG{TSTP} = 'IGNORE';
910 local $SIG{PIPE} = 'IGNORE';
912 my $oldAutoCommit = $FS::UID::AutoCommit;
913 local $FS::UID::AutoCommit = 0;
916 $self->select_for_update; #mutex
918 my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
919 my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
921 if ( $conf->exists('pkg-balances') ) {
922 # limit @payments & @credits to those w/ a pkgnum grepped from $self
923 my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
924 @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
925 @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
928 while ( $self->owed > 0 and ( @payments || @credits ) ) {
931 if ( @payments && @credits ) {
933 #decide which goes first by weight of top (unapplied) line item
935 my @open_lineitems = $self->open_cust_bill_pkg;
938 max( map { $_->part_pkg->pay_weight || 0 }
943 my $max_credit_weight =
944 max( map { $_->part_pkg->credit_weight || 0 }
950 #if both are the same... payments first? it has to be something
951 if ( $max_pay_weight >= $max_credit_weight ) {
957 } elsif ( @payments ) {
959 } elsif ( @credits ) {
962 die "guru meditation #12 and 35";
966 if ( $app eq 'pay' ) {
968 my $payment = shift @payments;
969 $unapp_amount = $payment->unapplied;
970 $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum };
971 $app->pkgnum( $payment->pkgnum )
972 if $conf->exists('pkg-balances') && $payment->pkgnum;
974 } elsif ( $app eq 'credit' ) {
976 my $credit = shift @credits;
977 $unapp_amount = $credit->credited;
978 $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
979 $app->pkgnum( $credit->pkgnum )
980 if $conf->exists('pkg-balances') && $credit->pkgnum;
983 die "guru meditation #12 and 35";
987 if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
988 warn "owed_pkgnum ". $app->pkgnum;
989 $owed = $self->owed_pkgnum($app->pkgnum);
993 next unless $owed > 0;
995 warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
996 $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
998 $app->invnum( $self->invnum );
1000 my $error = $app->insert(%options);
1002 $dbh->rollback if $oldAutoCommit;
1003 return "Error inserting ". $app->table. " record: $error";
1005 die $error if $error;
1009 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1014 =item generate_email OPTION => VALUE ...
1022 sender address, required
1026 alternate template name, optional
1030 text attachment arrayref, optional
1034 email subject, optional
1038 notice name instead of "Invoice", optional
1042 Returns an argument list to be passed to L<FS::Misc::send_email>.
1048 sub generate_email {
1052 my $conf = $self->conf;
1054 my $me = '[FS::cust_bill::generate_email]';
1057 'from' => $args{'from'},
1058 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
1062 'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
1063 'template' => $args{'template'},
1064 'notice_name' => ( $args{'notice_name'} || 'Invoice' ),
1065 'no_coupon' => $args{'no_coupon'},
1068 my $cust_main = $self->cust_main;
1070 if (ref($args{'to'}) eq 'ARRAY') {
1071 $return{'to'} = $args{'to'};
1073 $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
1074 $cust_main->invoicing_list
1078 if ( $conf->exists('invoice_html') ) {
1080 warn "$me creating HTML/text multipart message"
1083 $return{'nobody'} = 1;
1085 my $alternative = build MIME::Entity
1086 'Type' => 'multipart/alternative',
1087 #'Encoding' => '7bit',
1088 'Disposition' => 'inline'
1092 if ( $conf->exists('invoice_email_pdf')
1093 and scalar($conf->config('invoice_email_pdf_note')) ) {
1095 warn "$me using 'invoice_email_pdf_note' in multipart message"
1097 $data = [ map { $_ . "\n" }
1098 $conf->config('invoice_email_pdf_note')
1103 warn "$me not using 'invoice_email_pdf_note' in multipart message"
1105 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1106 $data = $args{'print_text'};
1108 $data = [ $self->print_text(\%opt) ];
1113 $alternative->attach(
1114 'Type' => 'text/plain',
1115 'Encoding' => 'quoted-printable',
1116 #'Encoding' => '7bit',
1118 'Disposition' => 'inline',
1125 if ( $conf->exists('invoice_email_pdf')
1126 and scalar($conf->config('invoice_email_pdf_note')) ) {
1128 $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
1132 $args{'from'} =~ /\@([\w\.\-]+)/;
1133 my $from = $1 || 'example.com';
1134 my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1137 my $agentnum = $cust_main->agentnum;
1138 if ( defined($args{'template'}) && length($args{'template'})
1139 && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
1142 $logo = 'logo_'. $args{'template'}. '.png';
1146 my $image_data = $conf->config_binary( $logo, $agentnum);
1148 $image = build MIME::Entity
1149 'Type' => 'image/png',
1150 'Encoding' => 'base64',
1151 'Data' => $image_data,
1152 'Filename' => 'logo.png',
1153 'Content-ID' => "<$content_id>",
1156 if ($conf->exists('invoice-barcode')) {
1157 my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1158 $barcode = build MIME::Entity
1159 'Type' => 'image/png',
1160 'Encoding' => 'base64',
1161 'Data' => $self->invoice_barcode(0),
1162 'Filename' => 'barcode.png',
1163 'Content-ID' => "<$barcode_content_id>",
1165 $opt{'barcode_cid'} = $barcode_content_id;
1168 $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
1171 $alternative->attach(
1172 'Type' => 'text/html',
1173 'Encoding' => 'quoted-printable',
1174 'Data' => [ '<html>',
1177 ' '. encode_entities($return{'subject'}),
1180 ' <body bgcolor="#e8e8e8">',
1185 'Disposition' => 'inline',
1186 #'Filename' => 'invoice.pdf',
1190 my @otherparts = ();
1191 if ( $cust_main->email_csv_cdr ) {
1193 push @otherparts, build MIME::Entity
1194 'Type' => 'text/csv',
1195 'Encoding' => '7bit',
1196 'Data' => [ map { "$_\n" }
1197 $self->call_details('prepend_billed_number' => 1)
1199 'Disposition' => 'attachment',
1200 'Filename' => 'usage-'. $self->invnum. '.csv',
1205 if ( $conf->exists('invoice_email_pdf') ) {
1210 # multipart/alternative
1216 my $related = build MIME::Entity 'Type' => 'multipart/related',
1217 'Encoding' => '7bit';
1219 #false laziness w/Misc::send_email
1220 $related->head->replace('Content-type',
1221 $related->mime_type.
1222 '; boundary="'. $related->head->multipart_boundary. '"'.
1223 '; type=multipart/alternative'
1226 $related->add_part($alternative);
1228 $related->add_part($image) if $image;
1230 my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
1232 $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
1236 #no other attachment:
1238 # multipart/alternative
1243 $return{'content-type'} = 'multipart/related';
1244 if ($conf->exists('invoice-barcode') && $barcode) {
1245 $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
1247 $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
1249 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
1250 #$return{'disposition'} = 'inline';
1256 if ( $conf->exists('invoice_email_pdf') ) {
1257 warn "$me creating PDF attachment"
1260 #mime parts arguments a la MIME::Entity->build().
1261 $return{'mimeparts'} = [
1262 { $self->mimebuild_pdf(\%opt) }
1266 if ( $conf->exists('invoice_email_pdf')
1267 and scalar($conf->config('invoice_email_pdf_note')) ) {
1269 warn "$me using 'invoice_email_pdf_note'"
1271 $return{'body'} = [ map { $_ . "\n" }
1272 $conf->config('invoice_email_pdf_note')
1277 warn "$me not using 'invoice_email_pdf_note'"
1279 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1280 $return{'body'} = $args{'print_text'};
1282 $return{'body'} = [ $self->print_text(\%opt) ];
1295 Returns a list suitable for passing to MIME::Entity->build(), representing
1296 this invoice as PDF attachment.
1303 'Type' => 'application/pdf',
1304 'Encoding' => 'base64',
1305 'Data' => [ $self->print_pdf(@_) ],
1306 'Disposition' => 'attachment',
1307 'Filename' => 'invoice-'. $self->invnum. '.pdf',
1311 =item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
1313 Sends this invoice to the destinations configured for this customer: sends
1314 email, prints and/or faxes. See L<FS::cust_main_invoice>.
1316 Options can be passed as a hashref (recommended) or as a list of up to
1317 four values for templatename, agentnum, invoice_from and amount.
1319 I<template>, if specified, is the name of a suffix for alternate invoices.
1321 I<agentnum>, if specified, means that this invoice will only be sent for customers
1322 of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a
1323 single agent) or an arrayref of agentnums.
1325 I<invoice_from>, if specified, overrides the default email invoice From: address.
1327 I<amount>, if specified, only sends the invoice if the total amount owed on this
1328 invoice and all older invoices is greater than the specified amount.
1330 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1334 sub queueable_send {
1337 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1338 or die "invalid invoice number: " . $opt{invnum};
1340 my @args = ( $opt{template}, $opt{agentnum} );
1341 push @args, $opt{invoice_from}
1342 if exists($opt{invoice_from}) && $opt{invoice_from};
1344 my $error = $self->send( @args );
1345 die $error if $error;
1351 my $conf = $self->conf;
1353 my( $template, $invoice_from, $notice_name );
1355 my $balance_over = 0;
1359 $template = $opt->{'template'} || '';
1360 if ( $agentnums = $opt->{'agentnum'} ) {
1361 $agentnums = [ $agentnums ] unless ref($agentnums);
1363 $invoice_from = $opt->{'invoice_from'};
1364 $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
1365 $notice_name = $opt->{'notice_name'};
1367 $template = scalar(@_) ? shift : '';
1368 if ( scalar(@_) && $_[0] ) {
1369 $agentnums = ref($_[0]) ? shift : [ shift ];
1371 $invoice_from = shift if scalar(@_);
1372 $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
1375 my $cust_main = $self->cust_main;
1377 return 'N/A' unless ! $agentnums
1378 or grep { $_ == $cust_main->agentnum } @$agentnums;
1381 unless $cust_main->total_owed_date($self->_date) > $balance_over;
1383 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1384 $conf->config('invoice_from', $cust_main->agentnum );
1387 'template' => $template,
1388 'invoice_from' => $invoice_from,
1389 'notice_name' => ( $notice_name || 'Invoice' ),
1392 my @invoicing_list = $cust_main->invoicing_list;
1394 #$self->email_invoice(\%opt)
1396 if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
1397 && ! $self->invoice_noemail;
1399 #$self->print_invoice(\%opt)
1401 if grep { $_ eq 'POST' } @invoicing_list; #postal
1403 $self->fax_invoice(\%opt)
1404 if grep { $_ eq 'FAX' } @invoicing_list; #fax
1410 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ]
1412 Emails this invoice.
1414 Options can be passed as a hashref (recommended) or as a list of up to
1415 two values for templatename and invoice_from.
1417 I<template>, if specified, is the name of a suffix for alternate invoices.
1419 I<invoice_from>, if specified, overrides the default email invoice From: address.
1421 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1425 sub queueable_email {
1428 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1429 or die "invalid invoice number: " . $opt{invnum};
1431 my %args = ( 'template' => $opt{template} );
1432 $args{$_} = $opt{$_}
1433 foreach grep { exists($opt{$_}) && $opt{$_} }
1434 qw( invoice_from notice_name no_coupon );
1436 my $error = $self->email( \%args );
1437 die $error if $error;
1441 #sub email_invoice {
1444 return if $self->hide;
1445 my $conf = $self->conf;
1447 my( $template, $invoice_from, $notice_name, $no_coupon );
1450 $template = $opt->{'template'} || '';
1451 $invoice_from = $opt->{'invoice_from'};
1452 $notice_name = $opt->{'notice_name'} || 'Invoice';
1453 $no_coupon = $opt->{'no_coupon'} || 0;
1455 $template = scalar(@_) ? shift : '';
1456 $invoice_from = shift if scalar(@_);
1457 $notice_name = 'Invoice';
1461 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1462 $conf->config('invoice_from', $self->cust_main->agentnum );
1464 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
1465 $self->cust_main->invoicing_list;
1467 if ( ! @invoicing_list ) { #no recipients
1468 if ( $conf->exists('cust_bill-no_recipients-error') ) {
1469 die 'No recipients for customer #'. $self->custnum;
1471 #default: better to notify this person than silence
1472 @invoicing_list = ($invoice_from);
1476 my $subject = $self->email_subject($template);
1478 my $error = send_email(
1479 $self->generate_email(
1480 'from' => $invoice_from,
1481 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
1482 'subject' => $subject,
1483 'template' => $template,
1484 'notice_name' => $notice_name,
1485 'no_coupon' => $no_coupon,
1488 die "can't email invoice: $error\n" if $error;
1489 #die "$error\n" if $error;
1495 my $conf = $self->conf;
1497 #my $template = scalar(@_) ? shift : '';
1500 my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
1503 my $cust_main = $self->cust_main;
1504 my $name = $cust_main->name;
1505 my $name_short = $cust_main->name_short;
1506 my $invoice_number = $self->invnum;
1507 my $invoice_date = $self->_date_pretty;
1509 eval qq("$subject");
1512 =item lpr_data HASHREF | [ TEMPLATE ]
1514 Returns the postscript or plaintext for this invoice as an arrayref.
1516 Options can be passed as a hashref (recommended) or as a single optional value
1519 I<template>, if specified, is the name of a suffix for alternate invoices.
1521 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1527 my $conf = $self->conf;
1528 my( $template, $notice_name );
1531 $template = $opt->{'template'} || '';
1532 $notice_name = $opt->{'notice_name'} || 'Invoice';
1534 $template = scalar(@_) ? shift : '';
1535 $notice_name = 'Invoice';
1539 'template' => $template,
1540 'notice_name' => $notice_name,
1543 my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
1544 [ $self->$method( \%opt ) ];
1547 =item print HASHREF | [ TEMPLATE ]
1549 Prints this invoice.
1551 Options can be passed as a hashref (recommended) or as a single optional
1554 I<template>, if specified, is the name of a suffix for alternate invoices.
1556 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1560 #sub print_invoice {
1563 return if $self->hide;
1564 my $conf = $self->conf;
1566 my( $template, $notice_name );
1569 $template = $opt->{'template'} || '';
1570 $notice_name = $opt->{'notice_name'} || 'Invoice';
1572 $template = scalar(@_) ? shift : '';
1573 $notice_name = 'Invoice';
1577 'template' => $template,
1578 'notice_name' => $notice_name,
1581 if($conf->exists('invoice_print_pdf')) {
1582 # Add the invoice to the current batch.
1583 $self->batch_invoice(\%opt);
1586 do_print $self->lpr_data(\%opt);
1590 =item fax_invoice HASHREF | [ TEMPLATE ]
1594 Options can be passed as a hashref (recommended) or as a single optional
1597 I<template>, if specified, is the name of a suffix for alternate invoices.
1599 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1605 return if $self->hide;
1606 my $conf = $self->conf;
1608 my( $template, $notice_name );
1611 $template = $opt->{'template'} || '';
1612 $notice_name = $opt->{'notice_name'} || 'Invoice';
1614 $template = scalar(@_) ? shift : '';
1615 $notice_name = 'Invoice';
1618 die 'FAX invoice destination not (yet?) supported with plain text invoices.'
1619 unless $conf->exists('invoice_latex');
1621 my $dialstring = $self->cust_main->getfield('fax');
1625 'template' => $template,
1626 'notice_name' => $notice_name,
1629 my $error = send_fax( 'docdata' => $self->lpr_data(\%opt),
1630 'dialstring' => $dialstring,
1632 die $error if $error;
1636 =item batch_invoice [ HASHREF ]
1638 Place this invoice into the open batch (see C<FS::bill_batch>). If there
1639 isn't an open batch, one will be created.
1644 my ($self, $opt) = @_;
1645 my $bill_batch = $self->get_open_bill_batch;
1646 my $cust_bill_batch = FS::cust_bill_batch->new({
1647 batchnum => $bill_batch->batchnum,
1648 invnum => $self->invnum,
1650 return $cust_bill_batch->insert($opt);
1653 =item get_open_batch
1655 Returns the currently open batch as an FS::bill_batch object, creating a new
1656 one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
1661 sub get_open_bill_batch {
1663 my $conf = $self->conf;
1664 my $hashref = { status => 'O' };
1665 $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
1666 ? $self->cust_main->agentnum
1668 my $batch = qsearchs('bill_batch', $hashref);
1669 return $batch if $batch;
1670 $batch = FS::bill_batch->new($hashref);
1671 my $error = $batch->insert;
1672 die $error if $error;
1676 =item ftp_invoice [ TEMPLATENAME ]
1678 Sends this invoice data via FTP.
1680 TEMPLATENAME is unused?
1686 my $conf = $self->conf;
1687 my $template = scalar(@_) ? shift : '';
1690 'protocol' => 'ftp',
1691 'server' => $conf->config('cust_bill-ftpserver'),
1692 'username' => $conf->config('cust_bill-ftpusername'),
1693 'password' => $conf->config('cust_bill-ftppassword'),
1694 'dir' => $conf->config('cust_bill-ftpdir'),
1695 'format' => $conf->config('cust_bill-ftpformat'),
1699 =item spool_invoice [ TEMPLATENAME ]
1701 Spools this invoice data (see L<FS::spool_csv>)
1703 TEMPLATENAME is unused?
1709 my $conf = $self->conf;
1710 my $template = scalar(@_) ? shift : '';
1713 'format' => $conf->config('cust_bill-spoolformat'),
1714 'agent_spools' => $conf->exists('cust_bill-spoolagent'),
1718 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
1720 Like B<send>, but only sends the invoice if it is the newest open invoice for
1725 sub send_if_newest {
1730 grep { $_->owed > 0 }
1731 qsearch('cust_bill', {
1732 'custnum' => $self->custnum,
1733 #'_date' => { op=>'>', value=>$self->_date },
1734 'invnum' => { op=>'>', value=>$self->invnum },
1741 =item send_csv OPTION => VALUE, ...
1743 Sends invoice as a CSV data-file to a remote host with the specified protocol.
1747 protocol - currently only "ftp"
1753 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
1754 and YYMMDDHHMMSS is a timestamp.
1756 See L</print_csv> for a description of the output format.
1761 my($self, %opt) = @_;
1765 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1766 mkdir $spooldir, 0700 unless -d $spooldir;
1768 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1769 my $file = "$spooldir/$tracctnum.csv";
1771 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1773 open(CSV, ">$file") or die "can't open $file: $!";
1781 if ( $opt{protocol} eq 'ftp' ) {
1782 eval "use Net::FTP;";
1784 $net = Net::FTP->new($opt{server}) or die @$;
1786 die "unknown protocol: $opt{protocol}";
1789 $net->login( $opt{username}, $opt{password} )
1790 or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
1792 $net->binary or die "can't set binary mode";
1794 $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
1796 $net->put($file) or die "can't put $file: $!";
1806 Spools CSV invoice data.
1812 =item format - any of FS::Misc::::Invoicing::spool_formats
1814 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
1815 customer has the corresponding invoice destinations set (see
1816 L<FS::cust_main_invoice>).
1818 =item agent_spools - if set to a true value, will spool to per-agent files
1819 rather than a single global file
1821 =item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
1822 append to that spool. L<FS::Cron::upload> will then send the spool file to
1825 =item balanceover - if set, only spools the invoice if the total amount owed on
1826 this invoice and all older invoices is greater than the specified amount.
1833 my($self, %opt) = @_;
1835 my $cust_main = $self->cust_main;
1837 if ( $opt{'dest'} ) {
1838 my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
1839 $cust_main->invoicing_list;
1840 return 'N/A' unless $invoicing_list{$opt{'dest'}}
1841 || ! keys %invoicing_list;
1844 if ( $opt{'balanceover'} ) {
1846 if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
1849 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1850 mkdir $spooldir, 0700 unless -d $spooldir;
1852 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1855 if ( $opt{'agent_spools'} ) {
1856 $file = 'agentnum'.$cust_main->agentnum;
1861 if ( $opt{'ftp_targetnum'} ) {
1862 $spooldir .= '/target'.$opt{'ftp_targetnum'};
1863 mkdir $spooldir, 0700 unless -d $spooldir;
1864 } # otherwise it just goes into export.xxx/cust_bill
1866 if ( lc($opt{'format'}) eq 'billco' ) {
1870 $file = "$spooldir/$file.csv";
1872 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1874 open(CSV, ">>$file") or die "can't open $file: $!";
1875 flock(CSV, LOCK_EX);
1880 if ( lc($opt{'format'}) eq 'billco' ) {
1882 flock(CSV, LOCK_UN);
1885 $file =~ s/-header.csv$/-detail.csv/;
1887 open(CSV,">>$file") or die "can't open $file: $!";
1888 flock(CSV, LOCK_EX);
1894 flock(CSV, LOCK_UN);
1901 =item print_csv OPTION => VALUE, ...
1903 Returns CSV data for this invoice.
1907 format - 'default', 'billco', 'oneline', 'bridgestone'
1909 Returns a list consisting of two scalars. The first is a single line of CSV
1910 header information for this invoice. The second is one or more lines of CSV
1911 detail information for this invoice.
1913 If I<format> is not specified or "default", the fields of the CSV file are as
1916 record_type, invnum, custnum, _date, charged, first, last, company, address1,
1917 address2, city, state, zip, country, pkg, setup, recur, sdate, edate
1921 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
1923 B<record_type> is C<cust_bill> for the initial header line only. The
1924 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
1925 fields are filled in.
1927 B<record_type> is C<cust_bill_pkg> for detail lines. Only the first two fields
1928 (B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
1931 =item invnum - invoice number
1933 =item custnum - customer number
1935 =item _date - invoice date
1937 =item charged - total invoice amount
1939 =item first - customer first name
1941 =item last - customer first name
1943 =item company - company name
1945 =item address1 - address line 1
1947 =item address2 - address line 1
1957 =item pkg - line item description
1959 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
1961 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
1963 =item sdate - start date for recurring fee
1965 =item edate - end date for recurring fee
1969 If I<format> is "billco", the fields of the header CSV file are as follows:
1971 +-------------------------------------------------------------------+
1972 | FORMAT HEADER FILE |
1973 |-------------------------------------------------------------------|
1974 | Field | Description | Name | Type | Width |
1975 | 1 | N/A-Leave Empty | RC | CHAR | 2 |
1976 | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 |
1977 | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 |
1978 | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 |
1979 | 5 | Transaction Zip Code | TRZIP | CHAR | 5 |
1980 | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 |
1981 | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 |
1982 | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 |
1983 | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 |
1984 | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 |
1985 | 11 | Transaction City Bill To | TRCITY | CHAR | 20 |
1986 | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 |
1987 | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 |
1988 | 14 | Bill Due Date | DUEDATE | CHAR | 10 |
1989 | 15 | Previous Balance | BALFWD | NUM* | 9 |
1990 | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 |
1991 | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 |
1992 | 18 | Total Amt Due | TOTALDUE | NUM* | 9 |
1993 | 19 | Total Amt Due | AMTDUE | NUM* | 9 |
1994 | 20 | 30 Day Aging | AMT30 | NUM* | 9 |
1995 | 21 | 60 Day Aging | AMT60 | NUM* | 9 |
1996 | 22 | 90 Day Aging | AMT90 | NUM* | 9 |
1997 | 23 | Y/N | AGESWITCH | CHAR | 1 |
1998 | 24 | Remittance automation | SCANLINE | CHAR | 100 |
1999 | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 |
2000 | 26 | Customer Reference Number | CUSTREF | CHAR | 15 |
2001 | 27 | Federal Tax*** | FEDTAX | NUM* | 9 |
2002 | 28 | State Tax*** | STATETAX | NUM* | 9 |
2003 | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 |
2004 +-------+-------------------------------+------------+------+-------+
2006 If I<format> is "billco", the fields of the detail CSV file are as follows:
2008 FORMAT FOR DETAIL FILE
2010 Field | Description | Name | Type | Width
2011 1 | N/A-Leave Empty | RC | CHAR | 2
2012 2 | N/A-Leave Empty | CUSTID | CHAR | 15
2013 3 | Account Number | TRACCTNUM | CHAR | 15
2014 4 | Invoice Number | TRINVOICE | CHAR | 15
2015 5 | Line Sequence (sort order) | LINESEQ | NUM | 6
2016 6 | Transaction Detail | DETAILS | CHAR | 100
2017 7 | Amount | AMT | NUM* | 9
2018 8 | Line Format Control** | LNCTRL | CHAR | 2
2019 9 | Grouping Code | GROUP | CHAR | 2
2020 10 | User Defined | ACCT CODE | CHAR | 15
2022 If format is 'oneline', there is no detail file. Each invoice has a
2023 header line only, with the fields:
2025 Agent number, agent name, customer number, first name, last name, address
2026 line 1, address line 2, city, state, zip, invoice date, invoice number,
2027 amount charged, amount due,
2029 and then, for each line item, three columns containing the package number,
2030 description, and amount.
2032 If format is 'bridgestone', there is no detail file. Each invoice has a
2033 header line with the following fields in a fixed-width format:
2035 Customer number (in display format), date, name (first last), company,
2036 address 1, address 2, city, state, zip.
2038 This is a mailing list format, and has no per-invoice fields. To avoid
2039 sending redundant notices, the spooling event should have a "once" or
2040 "once_percust_every" condition.
2045 my($self, %opt) = @_;
2047 eval "use Text::CSV_XS";
2050 my $cust_main = $self->cust_main;
2052 my $csv = Text::CSV_XS->new({'always_quote'=>1});
2054 if ( lc($opt{'format'}) eq 'billco' ) {
2057 $taxtotal += $_->{'amount'} foreach $self->_items_tax;
2059 my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
2061 my( $previous_balance, @unused ) = $self->previous; #previous balance
2063 my $pmt_cr_applied = 0;
2064 $pmt_cr_applied += $_->{'amount'}
2065 foreach ( $self->_items_payments, $self->_items_credits ) ;
2067 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2070 '', # 1 | N/A-Leave Empty CHAR 2
2071 '', # 2 | N/A-Leave Empty CHAR 15
2072 $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
2073 $self->invnum, # 4 | Transaction Invoice No CHAR 15
2074 $cust_main->zip, # 5 | Transaction Zip Code CHAR 5
2075 $cust_main->company, # 6 | Transaction Company Bill To CHAR 30
2076 #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30
2077 $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30
2078 $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30
2079 $cust_main->address1, # 9 | Bill To Street Address CHAR 30
2080 '', # 10 | Ancillary Billing Information CHAR 30
2081 $cust_main->city, # 11 | Transaction City Bill To CHAR 20
2082 $cust_main->state, # 12 | Transaction State Bill To CHAR 2
2085 time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10
2088 $duedate, # 14 | Bill Due Date CHAR 10
2090 $previous_balance, # 15 | Previous Balance NUM* 9
2091 $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9
2092 sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9
2093 $totaldue, # 18 | Total Amt Due NUM* 9
2094 $totaldue, # 19 | Total Amt Due NUM* 9
2095 '', # 20 | 30 Day Aging NUM* 9
2096 '', # 21 | 60 Day Aging NUM* 9
2097 '', # 22 | 90 Day Aging NUM* 9
2098 'N', # 23 | Y/N CHAR 1
2099 '', # 24 | Remittance automation CHAR 100
2100 $taxtotal, # 25 | Total Taxes & Fees NUM* 9
2101 $self->custnum, # 26 | Customer Reference Number CHAR 15
2102 '0', # 27 | Federal Tax*** NUM* 9
2103 sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9
2104 '0', # 29 | Other Taxes & Fees*** NUM* 9
2107 } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name?
2109 my ($previous_balance) = $self->previous;
2110 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2112 ($_->{pkgnum} || ''),
2115 } $self->_items_pkg;
2118 $cust_main->agentnum,
2119 $cust_main->agent->agent,
2123 $cust_main->address1,
2124 $cust_main->address2,
2130 time2str("%x", $self->_date),
2138 } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
2140 # bypass the CSV stuff and just return this
2141 my $longdate = time2str('%B %d, %Y', time); #current time, right?
2142 my $zip = $cust_main->zip;
2144 my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
2148 "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
2150 $cust_main->display_custnum,
2152 uc(substr($cust_main->contact_firstlast,0,30)),
2153 uc(substr($cust_main->company ,0,30)),
2154 uc(substr($cust_main->address1 ,0,30)),
2155 uc(substr($cust_main->address2 ,0,30)),
2156 uc(substr($cust_main->city ,0,20)),
2157 uc($cust_main->state),
2169 time2str("%x", $self->_date),
2170 sprintf("%.2f", $self->charged),
2171 ( map { $cust_main->getfield($_) }
2172 qw( first last company address1 address2 city state zip country ) ),
2174 ) or die "can't create csv";
2177 my $header = $csv->string. "\n";
2180 if ( lc($opt{'format'}) eq 'billco' ) {
2183 foreach my $item ( $self->_items_pkg ) {
2186 '', # 1 | N/A-Leave Empty CHAR 2
2187 '', # 2 | N/A-Leave Empty CHAR 15
2188 $opt{'tracctnum'}, # 3 | Account Number CHAR 15
2189 $self->invnum, # 4 | Invoice Number CHAR 15
2190 $lineseq++, # 5 | Line Sequence (sort order) NUM 6
2191 $item->{'description'}, # 6 | Transaction Detail CHAR 100
2192 $item->{'amount'}, # 7 | Amount NUM* 9
2193 '', # 8 | Line Format Control** CHAR 2
2194 '', # 9 | Grouping Code CHAR 2
2195 '', # 10 | User Defined CHAR 15
2198 $detail .= $csv->string. "\n";
2202 } elsif ( lc($opt{'format'}) eq 'oneline' ) {
2208 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2210 my($pkg, $setup, $recur, $sdate, $edate);
2211 if ( $cust_bill_pkg->pkgnum ) {
2213 ($pkg, $setup, $recur, $sdate, $edate) = (
2214 $cust_bill_pkg->part_pkg->pkg,
2215 ( $cust_bill_pkg->setup != 0
2216 ? sprintf("%.2f", $cust_bill_pkg->setup )
2218 ( $cust_bill_pkg->recur != 0
2219 ? sprintf("%.2f", $cust_bill_pkg->recur )
2221 ( $cust_bill_pkg->sdate
2222 ? time2str("%x", $cust_bill_pkg->sdate)
2224 ($cust_bill_pkg->edate
2225 ?time2str("%x", $cust_bill_pkg->edate)
2229 } else { #pkgnum tax
2230 next unless $cust_bill_pkg->setup != 0;
2231 $pkg = $cust_bill_pkg->desc;
2232 $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
2233 ( $sdate, $edate ) = ( '', '' );
2239 ( map { '' } (1..11) ),
2240 ($pkg, $setup, $recur, $sdate, $edate)
2241 ) or die "can't create csv";
2243 $detail .= $csv->string. "\n";
2249 ( $header, $detail );
2255 Pays this invoice with a compliemntary payment. If there is an error,
2256 returns the error, otherwise returns false.
2262 my $cust_pay = new FS::cust_pay ( {
2263 'invnum' => $self->invnum,
2264 'paid' => $self->owed,
2267 'payinfo' => $self->cust_main->payinfo,
2275 Attempts to pay this invoice with a credit card payment via a
2276 Business::OnlinePayment realtime gateway. See
2277 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2278 for supported processors.
2284 $self->realtime_bop( 'CC', @_ );
2289 Attempts to pay this invoice with an electronic check (ACH) payment via a
2290 Business::OnlinePayment realtime gateway. See
2291 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2292 for supported processors.
2298 $self->realtime_bop( 'ECHECK', @_ );
2303 Attempts to pay this invoice with phone bill (LEC) payment via a
2304 Business::OnlinePayment realtime gateway. See
2305 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2306 for supported processors.
2312 $self->realtime_bop( 'LEC', @_ );
2316 my( $self, $method ) = (shift,shift);
2317 my $conf = $self->conf;
2320 my $cust_main = $self->cust_main;
2321 my $balance = $cust_main->balance;
2322 my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
2323 $amount = sprintf("%.2f", $amount);
2324 return "not run (balance $balance)" unless $amount > 0;
2326 my $description = 'Internet Services';
2327 if ( $conf->exists('business-onlinepayment-description') ) {
2328 my $dtempl = $conf->config('business-onlinepayment-description');
2330 my $agent_obj = $cust_main->agent
2331 or die "can't retreive agent for $cust_main (agentnum ".
2332 $cust_main->agentnum. ")";
2333 my $agent = $agent_obj->agent;
2334 my $pkgs = join(', ',
2335 map { $_->part_pkg->pkg }
2336 grep { $_->pkgnum } $self->cust_bill_pkg
2338 $description = eval qq("$dtempl");
2341 $cust_main->realtime_bop($method, $amount,
2342 'description' => $description,
2343 'invnum' => $self->invnum,
2344 #this didn't do what we want, it just calls apply_payments_and_credits
2346 'apply_to_invoice' => 1,
2349 #this changes application behavior: auto payments
2350 #triggered against a specific invoice are now applied
2351 #to that invoice instead of oldest open.
2357 =item batch_card OPTION => VALUE...
2359 Adds a payment for this invoice to the pending credit card batch (see
2360 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2361 runs the payment using a realtime gateway.
2366 my ($self, %options) = @_;
2367 my $cust_main = $self->cust_main;
2369 $options{invnum} = $self->invnum;
2371 $cust_main->batch_card(%options);
2374 sub _agent_template {
2376 $self->cust_main->agent_template;
2379 sub _agent_invoice_from {
2381 $self->cust_main->agent_invoice_from;
2384 =item invoice_barcode DIR_OR_FALSE
2386 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
2387 it is taken as the temp directory where the PNG file will be generated and the
2388 PNG file name is returned. Otherwise, the PNG image itself is returned.
2392 sub invoice_barcode {
2393 my ($self, $dir) = (shift,shift);
2395 my $gdbar = new GD::Barcode('Code39',$self->invnum);
2396 die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
2397 my $gd = $gdbar->plot(Height => 30);
2400 my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
2404 ) or die "can't open temp file: $!\n";
2405 print $bh $gd->png or die "cannot write barcode to file: $!\n";
2406 my $png_file = $bh->filename;
2413 =item invnum_date_pretty
2415 Returns a string with the invoice number and date, for example:
2416 "Invoice #54 (3/20/2008)"
2420 sub invnum_date_pretty {
2422 $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
2425 #sub _items_extra_usage_sections {
2427 # my $escape = shift;
2429 # my %sections = ();
2431 # my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
2432 # foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2434 # next unless $cust_bill_pkg->pkgnum > 0;
2436 # foreach my $section ( keys %usage_class ) {
2438 # my $usage = $cust_bill_pkg->usage($section);
2440 # next unless $usage && $usage > 0;
2442 # $sections{$section} ||= 0;
2443 # $sections{$section} += $usage;
2449 # map { { 'description' => &{$escape}($_),
2450 # 'subtotal' => $sections{$_},
2451 # 'summarized' => '',
2452 # 'tax_section' => '',
2455 # sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
2459 sub _items_extra_usage_sections {
2461 my $conf = $self->conf;
2469 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2471 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2472 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2473 next unless $cust_bill_pkg->pkgnum > 0;
2475 foreach my $classnum ( keys %usage_class ) {
2476 my $section = $usage_class{$classnum}->classname;
2477 $classnums{$section} = $classnum;
2479 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
2480 my $amount = $detail->amount;
2481 next unless $amount && $amount > 0;
2483 $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
2484 $sections{$section}{amount} += $amount; #subtotal
2485 $sections{$section}{calls}++;
2486 $sections{$section}{duration} += $detail->duration;
2488 my $desc = $detail->regionname;
2489 my $description = $desc;
2490 $description = substr($desc, 0, $maxlength). '...'
2491 if $format eq 'latex' && length($desc) > $maxlength;
2493 $lines{$section}{$desc} ||= {
2494 description => &{$escape}($description),
2495 #pkgpart => $part_pkg->pkgpart,
2496 pkgnum => $cust_bill_pkg->pkgnum,
2501 #unit_amount => $cust_bill_pkg->unitrecur,
2502 quantity => $cust_bill_pkg->quantity,
2503 product_code => 'N/A',
2504 ext_description => [],
2507 $lines{$section}{$desc}{amount} += $amount;
2508 $lines{$section}{$desc}{calls}++;
2509 $lines{$section}{$desc}{duration} += $detail->duration;
2515 my %sectionmap = ();
2516 foreach (keys %sections) {
2517 my $usage_class = $usage_class{$classnums{$_}};
2518 $sectionmap{$_} = { 'description' => &{$escape}($_),
2519 'amount' => $sections{$_}{amount}, #subtotal
2520 'calls' => $sections{$_}{calls},
2521 'duration' => $sections{$_}{duration},
2523 'tax_section' => '',
2524 'sort_weight' => $usage_class->weight,
2525 ( $usage_class->format
2526 ? ( map { $_ => $usage_class->$_($format) }
2527 qw( description_generator header_generator total_generator total_line_generator )
2534 my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
2538 foreach my $section ( keys %lines ) {
2539 foreach my $line ( keys %{$lines{$section}} ) {
2540 my $l = $lines{$section}{$line};
2541 $l->{section} = $sectionmap{$section};
2542 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2543 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2548 return(\@sections, \@lines);
2554 my $end = $self->_date;
2556 # start at date of previous invoice + 1 second or 0 if no previous invoice
2557 my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
2558 $start = 0 if !$start;
2561 my $cust_main = $self->cust_main;
2562 my @pkgs = $cust_main->all_pkgs;
2563 my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
2566 foreach my $pkg ( @pkgs ) {
2567 my @h_cust_svc = $pkg->h_cust_svc($end);
2568 foreach my $h_cust_svc ( @h_cust_svc ) {
2569 next if grep {$_ eq $h_cust_svc->svcnum} @seen;
2570 next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
2572 my $inserted = $h_cust_svc->date_inserted;
2573 my $deleted = $h_cust_svc->date_deleted;
2574 my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
2576 $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted;
2578 # DID either activated or ported in; cannot be both for same DID simultaneously
2579 if ($inserted >= $start && $inserted <= $end && $phone_inserted
2580 && (!$phone_inserted->lnp_status
2581 || $phone_inserted->lnp_status eq ''
2582 || $phone_inserted->lnp_status eq 'native')) {
2585 else { # this one not so clean, should probably move to (h_)svc_phone
2586 my $phone_portedin = qsearchs( 'h_svc_phone',
2587 { 'svcnum' => $h_cust_svc->svcnum,
2588 'lnp_status' => 'portedin' },
2589 FS::h_svc_phone->sql_h_searchs($end),
2591 $num_portedin++ if $phone_portedin;
2594 # DID either deactivated or ported out; cannot be both for same DID simultaneously
2595 if($deleted >= $start && $deleted <= $end && $phone_deleted
2596 && (!$phone_deleted->lnp_status
2597 || $phone_deleted->lnp_status ne 'portingout')) {
2600 elsif($deleted >= $start && $deleted <= $end && $phone_deleted
2601 && $phone_deleted->lnp_status
2602 && $phone_deleted->lnp_status eq 'portingout') {
2606 # increment usage minutes
2607 if ( $phone_inserted ) {
2608 my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
2609 $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
2612 warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
2615 # don't look at this service again
2616 push @seen, $h_cust_svc->svcnum;
2620 $minutes = sprintf("%d", $minutes);
2621 ("Activated: $num_activated Ported-In: $num_portedin Deactivated: "
2622 . "$num_deactivated Ported-Out: $num_portedout ",
2623 "Total Minutes: $minutes");
2626 sub _items_accountcode_cdr {
2631 my $section = { 'amount' => 0,
2634 'sort_weight' => '',
2636 'description' => 'Usage by Account Code',
2642 my %accountcodes = ();
2644 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2645 next unless $cust_bill_pkg->pkgnum > 0;
2647 my @header = $cust_bill_pkg->details_header;
2648 next unless scalar(@header);
2649 $section->{'header'} = join(',',@header);
2651 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2653 $section->{'header'} = $detail->formatted('format' => $format)
2654 if($detail->detail eq $section->{'header'});
2656 my $accountcode = $detail->accountcode;
2657 next unless $accountcode;
2659 my $amount = $detail->amount;
2660 next unless $amount && $amount > 0;
2662 $accountcodes{$accountcode} ||= {
2663 description => $accountcode,
2670 product_code => 'N/A',
2671 section => $section,
2672 ext_description => [ $section->{'header'} ],
2676 $section->{'amount'} += $amount;
2677 $accountcodes{$accountcode}{'amount'} += $amount;
2678 $accountcodes{$accountcode}{calls}++;
2679 $accountcodes{$accountcode}{duration} += $detail->duration;
2680 push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
2684 foreach my $l ( values %accountcodes ) {
2685 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2686 my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
2687 foreach my $sorted_detail ( @sorted_detail ) {
2688 push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
2690 delete $l->{detail_temp};
2694 my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
2696 return ($section,\@sorted_lines);
2699 sub _items_svc_phone_sections {
2701 my $conf = $self->conf;
2709 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2711 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2712 $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
2714 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2715 next unless $cust_bill_pkg->pkgnum > 0;
2717 my @header = $cust_bill_pkg->details_header;
2718 next unless scalar(@header);
2720 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2722 my $phonenum = $detail->phonenum;
2723 next unless $phonenum;
2725 my $amount = $detail->amount;
2726 next unless $amount && $amount > 0;
2728 $sections{$phonenum} ||= { 'amount' => 0,
2731 'sort_weight' => -1,
2732 'phonenum' => $phonenum,
2734 $sections{$phonenum}{amount} += $amount; #subtotal
2735 $sections{$phonenum}{calls}++;
2736 $sections{$phonenum}{duration} += $detail->duration;
2738 my $desc = $detail->regionname;
2739 my $description = $desc;
2740 $description = substr($desc, 0, $maxlength). '...'
2741 if $format eq 'latex' && length($desc) > $maxlength;
2743 $lines{$phonenum}{$desc} ||= {
2744 description => &{$escape}($description),
2745 #pkgpart => $part_pkg->pkgpart,
2753 product_code => 'N/A',
2754 ext_description => [],
2757 $lines{$phonenum}{$desc}{amount} += $amount;
2758 $lines{$phonenum}{$desc}{calls}++;
2759 $lines{$phonenum}{$desc}{duration} += $detail->duration;
2761 my $line = $usage_class{$detail->classnum}->classname;
2762 $sections{"$phonenum $line"} ||=
2766 'sort_weight' => $usage_class{$detail->classnum}->weight,
2767 'phonenum' => $phonenum,
2768 'header' => [ @header ],
2770 $sections{"$phonenum $line"}{amount} += $amount; #subtotal
2771 $sections{"$phonenum $line"}{calls}++;
2772 $sections{"$phonenum $line"}{duration} += $detail->duration;
2774 $lines{"$phonenum $line"}{$desc} ||= {
2775 description => &{$escape}($description),
2776 #pkgpart => $part_pkg->pkgpart,
2784 product_code => 'N/A',
2785 ext_description => [],
2788 $lines{"$phonenum $line"}{$desc}{amount} += $amount;
2789 $lines{"$phonenum $line"}{$desc}{calls}++;
2790 $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
2791 push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
2792 $detail->formatted('format' => $format);
2797 my %sectionmap = ();
2798 my $simple = new FS::usage_class { format => 'simple' }; #bleh
2799 foreach ( keys %sections ) {
2800 my @header = @{ $sections{$_}{header} || [] };
2802 new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
2803 my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
2804 my $usage_class = $summary ? $simple : $usage_simple;
2805 my $ending = $summary ? ' usage charges' : '';
2808 $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
2810 $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
2811 'amount' => $sections{$_}{amount}, #subtotal
2812 'calls' => $sections{$_}{calls},
2813 'duration' => $sections{$_}{duration},
2815 'tax_section' => '',
2816 'phonenum' => $sections{$_}{phonenum},
2817 'sort_weight' => $sections{$_}{sort_weight},
2818 'post_total' => $summary, #inspire pagebreak
2820 ( map { $_ => $usage_class->$_($format, %gen_opt) }
2821 qw( description_generator
2824 total_line_generator
2831 my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
2832 $a->{sort_weight} <=> $b->{sort_weight}
2837 foreach my $section ( keys %lines ) {
2838 foreach my $line ( keys %{$lines{$section}} ) {
2839 my $l = $lines{$section}{$line};
2840 $l->{section} = $sectionmap{$section};
2841 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2842 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2847 if($conf->exists('phone_usage_class_summary')) {
2848 # this only works with Latex
2852 # after this, we'll have only two sections per DID:
2853 # Calls Summary and Calls Detail
2854 foreach my $section ( @sections ) {
2855 if($section->{'post_total'}) {
2856 $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
2857 $section->{'total_line_generator'} = sub { '' };
2858 $section->{'total_generator'} = sub { '' };
2859 $section->{'header_generator'} = sub { '' };
2860 $section->{'description_generator'} = '';
2861 push @newsections, $section;
2862 my %calls_detail = %$section;
2863 $calls_detail{'post_total'} = '';
2864 $calls_detail{'sort_weight'} = '';
2865 $calls_detail{'description_generator'} = sub { '' };
2866 $calls_detail{'header_generator'} = sub {
2867 return ' & Date/Time & Called Number & Duration & Price'
2868 if $format eq 'latex';
2871 $calls_detail{'description'} = 'Calls Detail: '
2872 . $section->{'phonenum'};
2873 push @newsections, \%calls_detail;
2877 # after this, each usage class is collapsed/summarized into a single
2878 # line under the Calls Summary section
2879 foreach my $newsection ( @newsections ) {
2880 if($newsection->{'post_total'}) { # this means Calls Summary
2881 foreach my $section ( @sections ) {
2882 next unless ($section->{'phonenum'} eq $newsection->{'phonenum'}
2883 && !$section->{'post_total'});
2884 my $newdesc = $section->{'description'};
2885 my $tn = $section->{'phonenum'};
2886 $newdesc =~ s/$tn//g;
2887 my $line = { ext_description => [],
2891 calls => $section->{'calls'},
2892 section => $newsection,
2893 duration => $section->{'duration'},
2894 description => $newdesc,
2895 amount => sprintf("%.2f",$section->{'amount'}),
2896 product_code => 'N/A',
2898 push @newlines, $line;
2903 # after this, Calls Details is populated with all CDRs
2904 foreach my $newsection ( @newsections ) {
2905 if(!$newsection->{'post_total'}) { # this means Calls Details
2906 foreach my $line ( @lines ) {
2907 next unless (scalar(@{$line->{'ext_description'}}) &&
2908 $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
2910 my @extdesc = @{$line->{'ext_description'}};
2912 foreach my $extdesc ( @extdesc ) {
2913 $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
2914 push @newextdesc, $extdesc;
2916 $line->{'ext_description'} = \@newextdesc;
2917 $line->{'section'} = $newsection;
2918 push @newlines, $line;
2923 return(\@newsections, \@newlines);
2926 return(\@sections, \@lines);
2930 sub _items_previous {
2932 my $conf = $self->conf;
2933 my $cust_main = $self->cust_main;
2934 my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
2936 foreach ( @pr_cust_bill ) {
2937 my $date = $conf->exists('invoice_show_prior_due_date')
2938 ? 'due '. $_->due_date2str($date_format)
2939 : time2str($date_format, $_->_date);
2941 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
2942 #'pkgpart' => 'N/A',
2944 'amount' => sprintf("%.2f", $_->owed),
2950 # 'description' => 'Previous Balance',
2951 # #'pkgpart' => 'N/A',
2952 # 'pkgnum' => 'N/A',
2953 # 'amount' => sprintf("%10.2f", $pr_total ),
2954 # 'ext_description' => [ map {
2955 # "Invoice ". $_->invnum.
2956 # " (". time2str("%x",$_->_date). ") ".
2957 # sprintf("%10.2f", $_->owed)
2958 # } @pr_cust_bill ],
2963 sub _items_credits {
2964 my( $self, %opt ) = @_;
2965 my $trim_len = $opt{'trim_len'} || 60;
2969 foreach ( $self->cust_credited ) {
2971 #something more elaborate if $_->amount ne $_->cust_credit->credited ?
2973 my $reason = substr($_->cust_credit->reason, 0, $trim_len);
2974 $reason .= '...' if length($reason) < length($_->cust_credit->reason);
2975 $reason = " ($reason) " if $reason;
2978 #'description' => 'Credit ref\#'. $_->crednum.
2979 # " (". time2str("%x",$_->cust_credit->_date) .")".
2981 'description' => $self->mt('Credit applied').' '.
2982 time2str($date_format,$_->cust_credit->_date). $reason,
2983 'amount' => sprintf("%.2f",$_->amount),
2991 sub _items_payments {
2995 #get & print payments
2996 foreach ( $self->cust_bill_pay ) {
2998 #something more elaborate if $_->amount ne ->cust_pay->paid ?
3001 'description' => $self->mt('Payment received').' '.
3002 time2str($date_format,$_->cust_pay->_date ),
3003 'amount' => sprintf("%.2f", $_->amount )
3011 =item call_details [ OPTION => VALUE ... ]
3013 Returns an array of CSV strings representing the call details for this invoice
3014 The only option available is the boolean prepend_billed_number
3019 my ($self, %opt) = @_;
3021 my $format_function = sub { shift };
3023 if ($opt{prepend_billed_number}) {
3024 $format_function = sub {
3028 $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
3033 my @details = map { $_->details( 'format_function' => $format_function,
3034 'escape_function' => sub{ return() },
3038 $self->cust_bill_pkg;
3039 my $header = $details[0];
3040 ( $header, grep { $_ ne $header } @details );
3050 =item process_reprint
3054 sub process_reprint {
3055 process_re_X('print', @_);
3058 =item process_reemail
3062 sub process_reemail {
3063 process_re_X('email', @_);
3071 process_re_X('fax', @_);
3079 process_re_X('ftp', @_);
3086 sub process_respool {
3087 process_re_X('spool', @_);
3090 use Storable qw(thaw);
3094 my( $method, $job ) = ( shift, shift );
3095 warn "$me process_re_X $method for job $job\n" if $DEBUG;
3097 my $param = thaw(decode_base64(shift));
3098 warn Dumper($param) if $DEBUG;
3109 # spool_invoice ftp_invoice fax_invoice print_invoice
3110 my($method, $job, %param ) = @_;
3112 warn "re_X $method for job $job with param:\n".
3113 join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
3116 #some false laziness w/search/cust_bill.html
3118 my $orderby = 'ORDER BY cust_bill._date';
3120 my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
3122 my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
3124 my @cust_bill = qsearch( {
3125 #'select' => "cust_bill.*",
3126 'table' => 'cust_bill',
3127 'addl_from' => $addl_from,
3129 'extra_sql' => $extra_sql,
3130 'order_by' => $orderby,
3134 $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
3136 warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
3139 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
3140 foreach my $cust_bill ( @cust_bill ) {
3141 $cust_bill->$method();
3143 if ( $job ) { #progressbar foo
3145 if ( time - $min_sec > $last ) {
3146 my $error = $job->update_statustext(
3147 int( 100 * $num / scalar(@cust_bill) )
3149 die $error if $error;
3160 =head1 CLASS METHODS
3166 Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
3171 my ($class, $start, $end) = @_;
3173 $class->paid_sql($start, $end). ' - '.
3174 $class->credited_sql($start, $end);
3179 Returns an SQL fragment to retreive the net amount (charged minus credited).
3184 my ($class, $start, $end) = @_;
3185 'charged - '. $class->credited_sql($start, $end);
3190 Returns an SQL fragment to retreive the amount paid against this invoice.
3195 my ($class, $start, $end) = @_;
3196 $start &&= "AND cust_bill_pay._date <= $start";
3197 $end &&= "AND cust_bill_pay._date > $end";
3198 $start = '' unless defined($start);
3199 $end = '' unless defined($end);
3200 "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
3201 WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )";
3206 Returns an SQL fragment to retreive the amount credited against this invoice.
3211 my ($class, $start, $end) = @_;
3212 $start &&= "AND cust_credit_bill._date <= $start";
3213 $end &&= "AND cust_credit_bill._date > $end";
3214 $start = '' unless defined($start);
3215 $end = '' unless defined($end);
3216 "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
3217 WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
3222 Returns an SQL fragment to retrieve the due date of an invoice.
3223 Currently only supported on PostgreSQL.
3228 my $conf = new FS::Conf;
3232 cust_bill.invoice_terms,
3233 cust_main.invoice_terms,
3234 \''.($conf->config('invoice_default_terms') || '').'\'
3235 ), E\'Net (\\\\d+)\'
3237 ) * 86400 + cust_bill._date'
3240 =item search_sql_where HASHREF
3242 Class method which returns an SQL WHERE fragment to search for parameters
3243 specified in HASHREF. Valid parameters are
3249 List reference of start date, end date, as UNIX timestamps.
3259 List reference of charged limits (exclusive).
3263 List reference of charged limits (exclusive).
3267 flag, return open invoices only
3271 flag, return net invoices only
3275 =item newest_percust
3279 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
3283 sub search_sql_where {
3284 my($class, $param) = @_;
3286 warn "$me search_sql_where called with params: \n".
3287 join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
3293 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
3294 push @search, "cust_main.agentnum = $1";
3298 if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
3299 push @search, "cust_main.refnum = $1";
3303 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
3304 push @search, "cust_bill.custnum = $1";
3308 if ( $param->{_date} ) {
3309 my($beginning, $ending) = @{$param->{_date}};
3311 push @search, "cust_bill._date >= $beginning",
3312 "cust_bill._date < $ending";
3316 if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
3317 push @search, "cust_bill.invnum >= $1";
3319 if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
3320 push @search, "cust_bill.invnum <= $1";
3324 if ( $param->{charged} ) {
3325 my @charged = ref($param->{charged})
3326 ? @{ $param->{charged} }
3327 : ($param->{charged});
3329 push @search, map { s/^charged/cust_bill.charged/; $_; }
3333 my $owed_sql = FS::cust_bill->owed_sql;
3336 if ( $param->{owed} ) {
3337 my @owed = ref($param->{owed})
3338 ? @{ $param->{owed} }
3340 push @search, map { s/^owed/$owed_sql/; $_; }
3345 push @search, "0 != $owed_sql"
3346 if $param->{'open'};
3347 push @search, '0 != '. FS::cust_bill->net_sql
3351 push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
3352 if $param->{'days'};
3355 if ( $param->{'newest_percust'} ) {
3357 #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
3358 #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
3360 my @newest_where = map { my $x = $_;
3361 $x =~ s/\bcust_bill\./newest_cust_bill./g;
3364 grep ! /^cust_main./, @search;
3365 my $newest_where = scalar(@newest_where)
3366 ? ' AND '. join(' AND ', @newest_where)
3370 push @search, "cust_bill._date = (
3371 SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
3372 WHERE newest_cust_bill.custnum = cust_bill.custnum
3378 #promised_date - also has an option to accept nulls
3379 if ( $param->{promised_date} ) {
3380 my($beginning, $ending, $null) = @{$param->{promised_date}};
3382 push @search, "(( cust_bill.promised_date >= $beginning AND ".
3383 "cust_bill.promised_date < $ending )" .
3384 ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
3387 #agent virtualization
3388 my $curuser = $FS::CurrentUser::CurrentUser;
3389 if ( $curuser->username eq 'fs_queue'
3390 && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
3392 my $newuser = qsearchs('access_user', {
3393 'username' => $username,
3397 $curuser = $newuser;
3399 warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
3402 push @search, $curuser->agentnums_sql;
3404 join(' AND ', @search );
3416 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
3417 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base