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; }
390 =item enable_previous
392 Whether to show the 'Previous Charges' section when printing this invoice.
393 The negation of the 'disable_previous_balance' config setting.
397 sub enable_previous {
399 my $agentnum = $self->cust_main->agentnum;
400 !$self->conf->exists('disable_previous_balance', $agentnum);
405 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
412 { 'table' => 'cust_bill_pkg',
413 'hashref' => { 'invnum' => $self->invnum },
414 'order_by' => 'ORDER BY billpkgnum',
419 =item cust_bill_pkg_pkgnum PKGNUM
421 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
426 sub cust_bill_pkg_pkgnum {
427 my( $self, $pkgnum ) = @_;
429 { 'table' => 'cust_bill_pkg',
430 'hashref' => { 'invnum' => $self->invnum,
433 'order_by' => 'ORDER BY billpkgnum',
440 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
447 my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
448 $self->cust_bill_pkg;
450 grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
455 Returns true if any of the packages (or their definitions) corresponding to the
456 line items for this invoice have the no_auto flag set.
462 grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
465 =item open_cust_bill_pkg
467 Returns the open line items for this invoice.
469 Note that cust_bill_pkg with both setup and recur fees are returned as two
470 separate line items, each with only one fee.
474 # modeled after cust_main::open_cust_bill
475 sub open_cust_bill_pkg {
478 # grep { $_->owed > 0 } $self->cust_bill_pkg
480 my %other = ( 'recur' => 'setup',
481 'setup' => 'recur', );
483 foreach my $field ( qw( recur setup )) {
484 push @open, map { $_->set( $other{$field}, 0 ); $_; }
485 grep { $_->owed($field) > 0 }
486 $self->cust_bill_pkg;
492 =item cust_bill_event
494 Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
498 sub cust_bill_event {
500 qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
503 =item num_cust_bill_event
505 Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
509 sub num_cust_bill_event {
512 "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
513 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
514 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
515 $sth->fetchrow_arrayref->[0];
520 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
524 #false laziness w/cust_pkg.pm
528 'table' => 'cust_event',
529 'addl_from' => 'JOIN part_event USING ( eventpart )',
530 'hashref' => { 'tablenum' => $self->invnum },
531 'extra_sql' => " AND eventtable = 'cust_bill' ",
537 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
541 #false laziness w/cust_pkg.pm
545 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
546 " WHERE tablenum = ? AND eventtable = 'cust_bill'";
547 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
548 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
549 $sth->fetchrow_arrayref->[0];
554 Returns the customer (see L<FS::cust_main>) for this invoice.
560 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
563 =item cust_suspend_if_balance_over AMOUNT
565 Suspends the customer associated with this invoice if the total amount owed on
566 this invoice and all older invoices is greater than the specified amount.
568 Returns a list: an empty list on success or a list of errors.
572 sub cust_suspend_if_balance_over {
573 my( $self, $amount ) = ( shift, shift );
574 my $cust_main = $self->cust_main;
575 if ( $cust_main->total_owed_date($self->_date) < $amount ) {
578 $cust_main->suspend(@_);
584 Depreciated. See the cust_credited method.
586 #Returns a list consisting of the total previous credited (see
587 #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
588 #outstanding credits (FS::cust_credit objects).
594 croak "FS::cust_bill->cust_credit depreciated; see ".
595 "FS::cust_bill->cust_credit_bill";
598 #my @cust_credit = sort { $a->_date <=> $b->_date }
599 # grep { $_->credited != 0 && $_->_date < $self->_date }
600 # qsearch('cust_credit', { 'custnum' => $self->custnum } )
602 #foreach (@cust_credit) { $total += $_->credited; }
603 #$total, @cust_credit;
608 Depreciated. See the cust_bill_pay method.
610 #Returns all payments (see L<FS::cust_pay>) for this invoice.
616 croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
618 #sort { $a->_date <=> $b->_date }
619 # qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
625 qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
628 sub cust_bill_pay_batch {
630 qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
635 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
641 map { $_ } #return $self->num_cust_bill_pay unless wantarray;
642 sort { $a->_date <=> $b->_date }
643 qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
648 =item cust_credit_bill
650 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
656 map { $_ } #return $self->num_cust_credit_bill unless wantarray;
657 sort { $a->_date <=> $b->_date }
658 qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
662 sub cust_credit_bill {
663 shift->cust_credited(@_);
666 #=item cust_bill_pay_pkgnum PKGNUM
668 #Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
669 #with matching pkgnum.
673 #sub cust_bill_pay_pkgnum {
674 # my( $self, $pkgnum ) = @_;
675 # map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
676 # sort { $a->_date <=> $b->_date }
677 # qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
678 # 'pkgnum' => $pkgnum,
683 =item cust_bill_pay_pkg PKGNUM
685 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
686 applied against the matching pkgnum.
690 sub cust_bill_pay_pkg {
691 my( $self, $pkgnum ) = @_;
694 'select' => 'cust_bill_pay_pkg.*',
695 'table' => 'cust_bill_pay_pkg',
696 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
697 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
698 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
699 " AND cust_bill_pkg.pkgnum = $pkgnum",
704 #=item cust_credited_pkgnum PKGNUM
706 #=item cust_credit_bill_pkgnum PKGNUM
708 #Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
709 #with matching pkgnum.
713 #sub cust_credited_pkgnum {
714 # my( $self, $pkgnum ) = @_;
715 # map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
716 # sort { $a->_date <=> $b->_date }
717 # qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
718 # 'pkgnum' => $pkgnum,
723 #sub cust_credit_bill_pkgnum {
724 # shift->cust_credited_pkgnum(@_);
727 =item cust_credit_bill_pkg PKGNUM
729 Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
730 applied against the matching pkgnum.
734 sub cust_credit_bill_pkg {
735 my( $self, $pkgnum ) = @_;
738 'select' => 'cust_credit_bill_pkg.*',
739 'table' => 'cust_credit_bill_pkg',
740 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
741 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
742 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
743 " AND cust_bill_pkg.pkgnum = $pkgnum",
748 =item cust_bill_batch
750 Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
754 sub cust_bill_batch {
756 qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
761 Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
762 hash keyed by term length.
768 FS::discount_plan->all($self);
773 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
780 my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
782 foreach (@taxlines) { $total += $_->setup; }
788 Returns the amount owed (still outstanding) on this invoice, which is charged
789 minus all payment applications (see L<FS::cust_bill_pay>) and credit
790 applications (see L<FS::cust_credit_bill>).
796 my $balance = $self->charged;
797 $balance -= $_->amount foreach ( $self->cust_bill_pay );
798 $balance -= $_->amount foreach ( $self->cust_credited );
799 $balance = sprintf( "%.2f", $balance);
800 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
805 my( $self, $pkgnum ) = @_;
807 #my $balance = $self->charged;
809 $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
811 $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum);
812 $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum);
814 $balance = sprintf( "%.2f", $balance);
815 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
821 Returns true if this invoice should be hidden. See the
822 selfservice-hide_invoices-taxclass configuraiton setting.
828 my $conf = $self->conf;
829 my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
831 my @cust_bill_pkg = $self->cust_bill_pkg;
832 my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
833 ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
836 =item apply_payments_and_credits [ OPTION => VALUE ... ]
838 Applies unapplied payments and credits to this invoice.
840 A hash of optional arguments may be passed. Currently "manual" is supported.
841 If true, a payment receipt is sent instead of a statement when
842 'payment_receipt_email' configuration option is set.
844 If there is an error, returns the error, otherwise returns false.
848 sub apply_payments_and_credits {
849 my( $self, %options ) = @_;
850 my $conf = $self->conf;
852 local $SIG{HUP} = 'IGNORE';
853 local $SIG{INT} = 'IGNORE';
854 local $SIG{QUIT} = 'IGNORE';
855 local $SIG{TERM} = 'IGNORE';
856 local $SIG{TSTP} = 'IGNORE';
857 local $SIG{PIPE} = 'IGNORE';
859 my $oldAutoCommit = $FS::UID::AutoCommit;
860 local $FS::UID::AutoCommit = 0;
863 $self->select_for_update; #mutex
865 my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
866 my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
868 if ( $conf->exists('pkg-balances') ) {
869 # limit @payments & @credits to those w/ a pkgnum grepped from $self
870 my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
871 @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
872 @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
875 while ( $self->owed > 0 and ( @payments || @credits ) ) {
878 if ( @payments && @credits ) {
880 #decide which goes first by weight of top (unapplied) line item
882 my @open_lineitems = $self->open_cust_bill_pkg;
885 max( map { $_->part_pkg->pay_weight || 0 }
890 my $max_credit_weight =
891 max( map { $_->part_pkg->credit_weight || 0 }
897 #if both are the same... payments first? it has to be something
898 if ( $max_pay_weight >= $max_credit_weight ) {
904 } elsif ( @payments ) {
906 } elsif ( @credits ) {
909 die "guru meditation #12 and 35";
913 if ( $app eq 'pay' ) {
915 my $payment = shift @payments;
916 $unapp_amount = $payment->unapplied;
917 $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum };
918 $app->pkgnum( $payment->pkgnum )
919 if $conf->exists('pkg-balances') && $payment->pkgnum;
921 } elsif ( $app eq 'credit' ) {
923 my $credit = shift @credits;
924 $unapp_amount = $credit->credited;
925 $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
926 $app->pkgnum( $credit->pkgnum )
927 if $conf->exists('pkg-balances') && $credit->pkgnum;
930 die "guru meditation #12 and 35";
934 if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
935 warn "owed_pkgnum ". $app->pkgnum;
936 $owed = $self->owed_pkgnum($app->pkgnum);
940 next unless $owed > 0;
942 warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
943 $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
945 $app->invnum( $self->invnum );
947 my $error = $app->insert(%options);
949 $dbh->rollback if $oldAutoCommit;
950 return "Error inserting ". $app->table. " record: $error";
952 die $error if $error;
956 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
961 =item generate_email OPTION => VALUE ...
969 sender address, required
973 alternate template name, optional
977 text attachment arrayref, optional
981 email subject, optional
985 notice name instead of "Invoice", optional
989 Returns an argument list to be passed to L<FS::Misc::send_email>.
999 my $conf = $self->conf;
1001 my $me = '[FS::cust_bill::generate_email]';
1004 'from' => $args{'from'},
1005 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
1009 'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
1010 'template' => $args{'template'},
1011 'notice_name' => ( $args{'notice_name'} || 'Invoice' ),
1012 'no_coupon' => $args{'no_coupon'},
1015 my $cust_main = $self->cust_main;
1017 if (ref($args{'to'}) eq 'ARRAY') {
1018 $return{'to'} = $args{'to'};
1020 $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
1021 $cust_main->invoicing_list
1025 if ( $conf->exists('invoice_html') ) {
1027 warn "$me creating HTML/text multipart message"
1030 $return{'nobody'} = 1;
1032 my $alternative = build MIME::Entity
1033 'Type' => 'multipart/alternative',
1034 #'Encoding' => '7bit',
1035 'Disposition' => 'inline'
1039 if ( $conf->exists('invoice_email_pdf')
1040 and scalar($conf->config('invoice_email_pdf_note')) ) {
1042 warn "$me using 'invoice_email_pdf_note' in multipart message"
1044 $data = [ map { $_ . "\n" }
1045 $conf->config('invoice_email_pdf_note')
1050 warn "$me not using 'invoice_email_pdf_note' in multipart message"
1052 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1053 $data = $args{'print_text'};
1055 $data = [ $self->print_text(\%opt) ];
1060 $alternative->attach(
1061 'Type' => 'text/plain',
1062 'Encoding' => 'quoted-printable',
1063 #'Encoding' => '7bit',
1065 'Disposition' => 'inline',
1072 if ( $conf->exists('invoice_email_pdf')
1073 and scalar($conf->config('invoice_email_pdf_note')) ) {
1075 $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
1079 $args{'from'} =~ /\@([\w\.\-]+)/;
1080 my $from = $1 || 'example.com';
1081 my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1084 my $agentnum = $cust_main->agentnum;
1085 if ( defined($args{'template'}) && length($args{'template'})
1086 && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
1089 $logo = 'logo_'. $args{'template'}. '.png';
1093 my $image_data = $conf->config_binary( $logo, $agentnum);
1095 $image = build MIME::Entity
1096 'Type' => 'image/png',
1097 'Encoding' => 'base64',
1098 'Data' => $image_data,
1099 'Filename' => 'logo.png',
1100 'Content-ID' => "<$content_id>",
1103 if ($conf->exists('invoice-barcode')) {
1104 my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1105 $barcode = build MIME::Entity
1106 'Type' => 'image/png',
1107 'Encoding' => 'base64',
1108 'Data' => $self->invoice_barcode(0),
1109 'Filename' => 'barcode.png',
1110 'Content-ID' => "<$barcode_content_id>",
1112 $opt{'barcode_cid'} = $barcode_content_id;
1115 $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
1118 $alternative->attach(
1119 'Type' => 'text/html',
1120 'Encoding' => 'quoted-printable',
1121 'Data' => [ '<html>',
1124 ' '. encode_entities($return{'subject'}),
1127 ' <body bgcolor="#e8e8e8">',
1132 'Disposition' => 'inline',
1133 #'Filename' => 'invoice.pdf',
1137 my @otherparts = ();
1138 if ( $cust_main->email_csv_cdr ) {
1140 push @otherparts, build MIME::Entity
1141 'Type' => 'text/csv',
1142 'Encoding' => '7bit',
1143 'Data' => [ map { "$_\n" }
1144 $self->call_details('prepend_billed_number' => 1)
1146 'Disposition' => 'attachment',
1147 'Filename' => 'usage-'. $self->invnum. '.csv',
1152 if ( $conf->exists('invoice_email_pdf') ) {
1157 # multipart/alternative
1163 my $related = build MIME::Entity 'Type' => 'multipart/related',
1164 'Encoding' => '7bit';
1166 #false laziness w/Misc::send_email
1167 $related->head->replace('Content-type',
1168 $related->mime_type.
1169 '; boundary="'. $related->head->multipart_boundary. '"'.
1170 '; type=multipart/alternative'
1173 $related->add_part($alternative);
1175 $related->add_part($image) if $image;
1177 my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
1179 $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
1183 #no other attachment:
1185 # multipart/alternative
1190 $return{'content-type'} = 'multipart/related';
1191 if ($conf->exists('invoice-barcode') && $barcode) {
1192 $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
1194 $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
1196 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
1197 #$return{'disposition'} = 'inline';
1203 if ( $conf->exists('invoice_email_pdf') ) {
1204 warn "$me creating PDF attachment"
1207 #mime parts arguments a la MIME::Entity->build().
1208 $return{'mimeparts'} = [
1209 { $self->mimebuild_pdf(\%opt) }
1213 if ( $conf->exists('invoice_email_pdf')
1214 and scalar($conf->config('invoice_email_pdf_note')) ) {
1216 warn "$me using 'invoice_email_pdf_note'"
1218 $return{'body'} = [ map { $_ . "\n" }
1219 $conf->config('invoice_email_pdf_note')
1224 warn "$me not using 'invoice_email_pdf_note'"
1226 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1227 $return{'body'} = $args{'print_text'};
1229 $return{'body'} = [ $self->print_text(\%opt) ];
1242 Returns a list suitable for passing to MIME::Entity->build(), representing
1243 this invoice as PDF attachment.
1250 'Type' => 'application/pdf',
1251 'Encoding' => 'base64',
1252 'Data' => [ $self->print_pdf(@_) ],
1253 'Disposition' => 'attachment',
1254 'Filename' => 'invoice-'. $self->invnum. '.pdf',
1258 =item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
1260 Sends this invoice to the destinations configured for this customer: sends
1261 email, prints and/or faxes. See L<FS::cust_main_invoice>.
1263 Options can be passed as a hashref (recommended) or as a list of up to
1264 four values for templatename, agentnum, invoice_from and amount.
1266 I<template>, if specified, is the name of a suffix for alternate invoices.
1268 I<agentnum>, if specified, means that this invoice will only be sent for customers
1269 of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a
1270 single agent) or an arrayref of agentnums.
1272 I<invoice_from>, if specified, overrides the default email invoice From: address.
1274 I<amount>, if specified, only sends the invoice if the total amount owed on this
1275 invoice and all older invoices is greater than the specified amount.
1277 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1281 sub queueable_send {
1284 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1285 or die "invalid invoice number: " . $opt{invnum};
1287 my @args = ( $opt{template}, $opt{agentnum} );
1288 push @args, $opt{invoice_from}
1289 if exists($opt{invoice_from}) && $opt{invoice_from};
1291 my $error = $self->send( @args );
1292 die $error if $error;
1298 my $conf = $self->conf;
1300 my( $template, $invoice_from, $notice_name );
1302 my $balance_over = 0;
1306 $template = $opt->{'template'} || '';
1307 if ( $agentnums = $opt->{'agentnum'} ) {
1308 $agentnums = [ $agentnums ] unless ref($agentnums);
1310 $invoice_from = $opt->{'invoice_from'};
1311 $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
1312 $notice_name = $opt->{'notice_name'};
1314 $template = scalar(@_) ? shift : '';
1315 if ( scalar(@_) && $_[0] ) {
1316 $agentnums = ref($_[0]) ? shift : [ shift ];
1318 $invoice_from = shift if scalar(@_);
1319 $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
1322 my $cust_main = $self->cust_main;
1324 return 'N/A' unless ! $agentnums
1325 or grep { $_ == $cust_main->agentnum } @$agentnums;
1328 unless $cust_main->total_owed_date($self->_date) > $balance_over;
1330 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1331 $conf->config('invoice_from', $cust_main->agentnum );
1334 'template' => $template,
1335 'invoice_from' => $invoice_from,
1336 'notice_name' => ( $notice_name || 'Invoice' ),
1339 my @invoicing_list = $cust_main->invoicing_list;
1341 #$self->email_invoice(\%opt)
1343 if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
1344 && ! $self->invoice_noemail;
1346 #$self->print_invoice(\%opt)
1348 if grep { $_ eq 'POST' } @invoicing_list; #postal
1350 $self->fax_invoice(\%opt)
1351 if grep { $_ eq 'FAX' } @invoicing_list; #fax
1357 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ]
1359 Emails this invoice.
1361 Options can be passed as a hashref (recommended) or as a list of up to
1362 two values for templatename and invoice_from.
1364 I<template>, if specified, is the name of a suffix for alternate invoices.
1366 I<invoice_from>, if specified, overrides the default email invoice From: address.
1368 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1372 sub queueable_email {
1375 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1376 or die "invalid invoice number: " . $opt{invnum};
1378 my %args = ( 'template' => $opt{template} );
1379 $args{$_} = $opt{$_}
1380 foreach grep { exists($opt{$_}) && $opt{$_} }
1381 qw( invoice_from notice_name no_coupon );
1383 my $error = $self->email( \%args );
1384 die $error if $error;
1388 #sub email_invoice {
1391 return if $self->hide;
1392 my $conf = $self->conf;
1394 my( $template, $invoice_from, $notice_name, $no_coupon );
1397 $template = $opt->{'template'} || '';
1398 $invoice_from = $opt->{'invoice_from'};
1399 $notice_name = $opt->{'notice_name'} || 'Invoice';
1400 $no_coupon = $opt->{'no_coupon'} || 0;
1402 $template = scalar(@_) ? shift : '';
1403 $invoice_from = shift if scalar(@_);
1404 $notice_name = 'Invoice';
1408 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1409 $conf->config('invoice_from', $self->cust_main->agentnum );
1411 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
1412 $self->cust_main->invoicing_list;
1414 if ( ! @invoicing_list ) { #no recipients
1415 if ( $conf->exists('cust_bill-no_recipients-error') ) {
1416 die 'No recipients for customer #'. $self->custnum;
1418 #default: better to notify this person than silence
1419 @invoicing_list = ($invoice_from);
1423 my $subject = $self->email_subject($template);
1425 my $error = send_email(
1426 $self->generate_email(
1427 'from' => $invoice_from,
1428 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
1429 'subject' => $subject,
1430 'template' => $template,
1431 'notice_name' => $notice_name,
1432 'no_coupon' => $no_coupon,
1435 die "can't email invoice: $error\n" if $error;
1436 #die "$error\n" if $error;
1442 my $conf = $self->conf;
1444 #my $template = scalar(@_) ? shift : '';
1447 my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
1450 my $cust_main = $self->cust_main;
1451 my $name = $cust_main->name;
1452 my $name_short = $cust_main->name_short;
1453 my $invoice_number = $self->invnum;
1454 my $invoice_date = $self->_date_pretty;
1456 eval qq("$subject");
1459 =item lpr_data HASHREF | [ TEMPLATE ]
1461 Returns the postscript or plaintext for this invoice as an arrayref.
1463 Options can be passed as a hashref (recommended) or as a single optional value
1466 I<template>, if specified, is the name of a suffix for alternate invoices.
1468 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1474 my $conf = $self->conf;
1475 my( $template, $notice_name );
1478 $template = $opt->{'template'} || '';
1479 $notice_name = $opt->{'notice_name'} || 'Invoice';
1481 $template = scalar(@_) ? shift : '';
1482 $notice_name = 'Invoice';
1486 'template' => $template,
1487 'notice_name' => $notice_name,
1490 my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
1491 [ $self->$method( \%opt ) ];
1494 =item print HASHREF | [ TEMPLATE ]
1496 Prints this invoice.
1498 Options can be passed as a hashref (recommended) or as a single optional
1501 I<template>, if specified, is the name of a suffix for alternate invoices.
1503 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1507 #sub print_invoice {
1510 return if $self->hide;
1511 my $conf = $self->conf;
1513 my( $template, $notice_name );
1516 $template = $opt->{'template'} || '';
1517 $notice_name = $opt->{'notice_name'} || 'Invoice';
1519 $template = scalar(@_) ? shift : '';
1520 $notice_name = 'Invoice';
1524 'template' => $template,
1525 'notice_name' => $notice_name,
1528 if($conf->exists('invoice_print_pdf')) {
1529 # Add the invoice to the current batch.
1530 $self->batch_invoice(\%opt);
1533 do_print $self->lpr_data(\%opt);
1537 =item fax_invoice HASHREF | [ TEMPLATE ]
1541 Options can be passed as a hashref (recommended) or as a single optional
1544 I<template>, if specified, is the name of a suffix for alternate invoices.
1546 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1552 return if $self->hide;
1553 my $conf = $self->conf;
1555 my( $template, $notice_name );
1558 $template = $opt->{'template'} || '';
1559 $notice_name = $opt->{'notice_name'} || 'Invoice';
1561 $template = scalar(@_) ? shift : '';
1562 $notice_name = 'Invoice';
1565 die 'FAX invoice destination not (yet?) supported with plain text invoices.'
1566 unless $conf->exists('invoice_latex');
1568 my $dialstring = $self->cust_main->getfield('fax');
1572 'template' => $template,
1573 'notice_name' => $notice_name,
1576 my $error = send_fax( 'docdata' => $self->lpr_data(\%opt),
1577 'dialstring' => $dialstring,
1579 die $error if $error;
1583 =item batch_invoice [ HASHREF ]
1585 Place this invoice into the open batch (see C<FS::bill_batch>). If there
1586 isn't an open batch, one will be created.
1591 my ($self, $opt) = @_;
1592 my $bill_batch = $self->get_open_bill_batch;
1593 my $cust_bill_batch = FS::cust_bill_batch->new({
1594 batchnum => $bill_batch->batchnum,
1595 invnum => $self->invnum,
1597 return $cust_bill_batch->insert($opt);
1600 =item get_open_batch
1602 Returns the currently open batch as an FS::bill_batch object, creating a new
1603 one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
1608 sub get_open_bill_batch {
1610 my $conf = $self->conf;
1611 my $hashref = { status => 'O' };
1612 $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
1613 ? $self->cust_main->agentnum
1615 my $batch = qsearchs('bill_batch', $hashref);
1616 return $batch if $batch;
1617 $batch = FS::bill_batch->new($hashref);
1618 my $error = $batch->insert;
1619 die $error if $error;
1623 =item ftp_invoice [ TEMPLATENAME ]
1625 Sends this invoice data via FTP.
1627 TEMPLATENAME is unused?
1633 my $conf = $self->conf;
1634 my $template = scalar(@_) ? shift : '';
1637 'protocol' => 'ftp',
1638 'server' => $conf->config('cust_bill-ftpserver'),
1639 'username' => $conf->config('cust_bill-ftpusername'),
1640 'password' => $conf->config('cust_bill-ftppassword'),
1641 'dir' => $conf->config('cust_bill-ftpdir'),
1642 'format' => $conf->config('cust_bill-ftpformat'),
1646 =item spool_invoice [ TEMPLATENAME ]
1648 Spools this invoice data (see L<FS::spool_csv>)
1650 TEMPLATENAME is unused?
1656 my $conf = $self->conf;
1657 my $template = scalar(@_) ? shift : '';
1660 'format' => $conf->config('cust_bill-spoolformat'),
1661 'agent_spools' => $conf->exists('cust_bill-spoolagent'),
1665 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
1667 Like B<send>, but only sends the invoice if it is the newest open invoice for
1672 sub send_if_newest {
1677 grep { $_->owed > 0 }
1678 qsearch('cust_bill', {
1679 'custnum' => $self->custnum,
1680 #'_date' => { op=>'>', value=>$self->_date },
1681 'invnum' => { op=>'>', value=>$self->invnum },
1688 =item send_csv OPTION => VALUE, ...
1690 Sends invoice as a CSV data-file to a remote host with the specified protocol.
1694 protocol - currently only "ftp"
1700 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
1701 and YYMMDDHHMMSS is a timestamp.
1703 See L</print_csv> for a description of the output format.
1708 my($self, %opt) = @_;
1712 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1713 mkdir $spooldir, 0700 unless -d $spooldir;
1715 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1716 my $file = "$spooldir/$tracctnum.csv";
1718 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1720 open(CSV, ">$file") or die "can't open $file: $!";
1728 if ( $opt{protocol} eq 'ftp' ) {
1729 eval "use Net::FTP;";
1731 $net = Net::FTP->new($opt{server}) or die @$;
1733 die "unknown protocol: $opt{protocol}";
1736 $net->login( $opt{username}, $opt{password} )
1737 or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
1739 $net->binary or die "can't set binary mode";
1741 $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
1743 $net->put($file) or die "can't put $file: $!";
1753 Spools CSV invoice data.
1759 =item format - any of FS::Misc::::Invoicing::spool_formats
1761 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
1762 customer has the corresponding invoice destinations set (see
1763 L<FS::cust_main_invoice>).
1765 =item agent_spools - if set to a true value, will spool to per-agent files
1766 rather than a single global file
1768 =item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
1769 append to that spool. L<FS::Cron::upload> will then send the spool file to
1772 =item balanceover - if set, only spools the invoice if the total amount owed on
1773 this invoice and all older invoices is greater than the specified amount.
1780 my($self, %opt) = @_;
1782 my $cust_main = $self->cust_main;
1784 if ( $opt{'dest'} ) {
1785 my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
1786 $cust_main->invoicing_list;
1787 return 'N/A' unless $invoicing_list{$opt{'dest'}}
1788 || ! keys %invoicing_list;
1791 if ( $opt{'balanceover'} ) {
1793 if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
1796 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1797 mkdir $spooldir, 0700 unless -d $spooldir;
1799 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1802 if ( $opt{'agent_spools'} ) {
1803 $file = 'agentnum'.$cust_main->agentnum;
1808 if ( $opt{'ftp_targetnum'} ) {
1809 $spooldir .= '/target'.$opt{'ftp_targetnum'};
1810 mkdir $spooldir, 0700 unless -d $spooldir;
1811 } # otherwise it just goes into export.xxx/cust_bill
1813 if ( lc($opt{'format'}) eq 'billco' ) {
1817 $file = "$spooldir/$file.csv";
1819 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1821 open(CSV, ">>$file") or die "can't open $file: $!";
1822 flock(CSV, LOCK_EX);
1827 if ( lc($opt{'format'}) eq 'billco' ) {
1829 flock(CSV, LOCK_UN);
1832 $file =~ s/-header.csv$/-detail.csv/;
1834 open(CSV,">>$file") or die "can't open $file: $!";
1835 flock(CSV, LOCK_EX);
1841 flock(CSV, LOCK_UN);
1848 =item print_csv OPTION => VALUE, ...
1850 Returns CSV data for this invoice.
1854 format - 'default', 'billco', 'oneline', 'bridgestone'
1856 Returns a list consisting of two scalars. The first is a single line of CSV
1857 header information for this invoice. The second is one or more lines of CSV
1858 detail information for this invoice.
1860 If I<format> is not specified or "default", the fields of the CSV file are as
1863 record_type, invnum, custnum, _date, charged, first, last, company, address1,
1864 address2, city, state, zip, country, pkg, setup, recur, sdate, edate
1868 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
1870 B<record_type> is C<cust_bill> for the initial header line only. The
1871 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
1872 fields are filled in.
1874 B<record_type> is C<cust_bill_pkg> for detail lines. Only the first two fields
1875 (B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
1878 =item invnum - invoice number
1880 =item custnum - customer number
1882 =item _date - invoice date
1884 =item charged - total invoice amount
1886 =item first - customer first name
1888 =item last - customer first name
1890 =item company - company name
1892 =item address1 - address line 1
1894 =item address2 - address line 1
1904 =item pkg - line item description
1906 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
1908 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
1910 =item sdate - start date for recurring fee
1912 =item edate - end date for recurring fee
1916 If I<format> is "billco", the fields of the header CSV file are as follows:
1918 +-------------------------------------------------------------------+
1919 | FORMAT HEADER FILE |
1920 |-------------------------------------------------------------------|
1921 | Field | Description | Name | Type | Width |
1922 | 1 | N/A-Leave Empty | RC | CHAR | 2 |
1923 | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 |
1924 | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 |
1925 | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 |
1926 | 5 | Transaction Zip Code | TRZIP | CHAR | 5 |
1927 | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 |
1928 | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 |
1929 | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 |
1930 | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 |
1931 | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 |
1932 | 11 | Transaction City Bill To | TRCITY | CHAR | 20 |
1933 | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 |
1934 | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 |
1935 | 14 | Bill Due Date | DUEDATE | CHAR | 10 |
1936 | 15 | Previous Balance | BALFWD | NUM* | 9 |
1937 | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 |
1938 | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 |
1939 | 18 | Total Amt Due | TOTALDUE | NUM* | 9 |
1940 | 19 | Total Amt Due | AMTDUE | NUM* | 9 |
1941 | 20 | 30 Day Aging | AMT30 | NUM* | 9 |
1942 | 21 | 60 Day Aging | AMT60 | NUM* | 9 |
1943 | 22 | 90 Day Aging | AMT90 | NUM* | 9 |
1944 | 23 | Y/N | AGESWITCH | CHAR | 1 |
1945 | 24 | Remittance automation | SCANLINE | CHAR | 100 |
1946 | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 |
1947 | 26 | Customer Reference Number | CUSTREF | CHAR | 15 |
1948 | 27 | Federal Tax*** | FEDTAX | NUM* | 9 |
1949 | 28 | State Tax*** | STATETAX | NUM* | 9 |
1950 | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 |
1951 +-------+-------------------------------+------------+------+-------+
1953 If I<format> is "billco", the fields of the detail CSV file are as follows:
1955 FORMAT FOR DETAIL FILE
1957 Field | Description | Name | Type | Width
1958 1 | N/A-Leave Empty | RC | CHAR | 2
1959 2 | N/A-Leave Empty | CUSTID | CHAR | 15
1960 3 | Account Number | TRACCTNUM | CHAR | 15
1961 4 | Invoice Number | TRINVOICE | CHAR | 15
1962 5 | Line Sequence (sort order) | LINESEQ | NUM | 6
1963 6 | Transaction Detail | DETAILS | CHAR | 100
1964 7 | Amount | AMT | NUM* | 9
1965 8 | Line Format Control** | LNCTRL | CHAR | 2
1966 9 | Grouping Code | GROUP | CHAR | 2
1967 10 | User Defined | ACCT CODE | CHAR | 15
1969 If format is 'oneline', there is no detail file. Each invoice has a
1970 header line only, with the fields:
1972 Agent number, agent name, customer number, first name, last name, address
1973 line 1, address line 2, city, state, zip, invoice date, invoice number,
1974 amount charged, amount due,
1976 and then, for each line item, three columns containing the package number,
1977 description, and amount.
1979 If format is 'bridgestone', there is no detail file. Each invoice has a
1980 header line with the following fields in a fixed-width format:
1982 Customer number (in display format), date, name (first last), company,
1983 address 1, address 2, city, state, zip.
1985 This is a mailing list format, and has no per-invoice fields. To avoid
1986 sending redundant notices, the spooling event should have a "once" or
1987 "once_percust_every" condition.
1992 my($self, %opt) = @_;
1994 eval "use Text::CSV_XS";
1997 my $cust_main = $self->cust_main;
1999 my $csv = Text::CSV_XS->new({'always_quote'=>1});
2001 if ( lc($opt{'format'}) eq 'billco' ) {
2004 $taxtotal += $_->{'amount'} foreach $self->_items_tax;
2006 my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
2008 my( $previous_balance, @unused ) = $self->previous; #previous balance
2010 my $pmt_cr_applied = 0;
2011 $pmt_cr_applied += $_->{'amount'}
2012 foreach ( $self->_items_payments, $self->_items_credits ) ;
2014 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2017 '', # 1 | N/A-Leave Empty CHAR 2
2018 '', # 2 | N/A-Leave Empty CHAR 15
2019 $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
2020 $self->invnum, # 4 | Transaction Invoice No CHAR 15
2021 $cust_main->zip, # 5 | Transaction Zip Code CHAR 5
2022 $cust_main->company, # 6 | Transaction Company Bill To CHAR 30
2023 #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30
2024 $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30
2025 $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30
2026 $cust_main->address1, # 9 | Bill To Street Address CHAR 30
2027 '', # 10 | Ancillary Billing Information CHAR 30
2028 $cust_main->city, # 11 | Transaction City Bill To CHAR 20
2029 $cust_main->state, # 12 | Transaction State Bill To CHAR 2
2032 time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10
2035 $duedate, # 14 | Bill Due Date CHAR 10
2037 $previous_balance, # 15 | Previous Balance NUM* 9
2038 $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9
2039 sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9
2040 $totaldue, # 18 | Total Amt Due NUM* 9
2041 $totaldue, # 19 | Total Amt Due NUM* 9
2042 '', # 20 | 30 Day Aging NUM* 9
2043 '', # 21 | 60 Day Aging NUM* 9
2044 '', # 22 | 90 Day Aging NUM* 9
2045 'N', # 23 | Y/N CHAR 1
2046 '', # 24 | Remittance automation CHAR 100
2047 $taxtotal, # 25 | Total Taxes & Fees NUM* 9
2048 $self->custnum, # 26 | Customer Reference Number CHAR 15
2049 '0', # 27 | Federal Tax*** NUM* 9
2050 sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9
2051 '0', # 29 | Other Taxes & Fees*** NUM* 9
2054 } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name?
2056 my ($previous_balance) = $self->previous;
2057 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2059 ($_->{pkgnum} || ''),
2062 } $self->_items_pkg;
2065 $cust_main->agentnum,
2066 $cust_main->agent->agent,
2070 $cust_main->address1,
2071 $cust_main->address2,
2077 time2str("%x", $self->_date),
2085 } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
2087 # bypass the CSV stuff and just return this
2088 my $longdate = time2str('%B %d, %Y', time); #current time, right?
2089 my $zip = $cust_main->zip;
2091 my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
2095 "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
2097 $cust_main->display_custnum,
2099 uc(substr($cust_main->contact_firstlast,0,30)),
2100 uc(substr($cust_main->company ,0,30)),
2101 uc(substr($cust_main->address1 ,0,30)),
2102 uc(substr($cust_main->address2 ,0,30)),
2103 uc(substr($cust_main->city ,0,20)),
2104 uc($cust_main->state),
2116 time2str("%x", $self->_date),
2117 sprintf("%.2f", $self->charged),
2118 ( map { $cust_main->getfield($_) }
2119 qw( first last company address1 address2 city state zip country ) ),
2121 ) or die "can't create csv";
2124 my $header = $csv->string. "\n";
2127 if ( lc($opt{'format'}) eq 'billco' ) {
2130 foreach my $item ( $self->_items_pkg ) {
2133 '', # 1 | N/A-Leave Empty CHAR 2
2134 '', # 2 | N/A-Leave Empty CHAR 15
2135 $opt{'tracctnum'}, # 3 | Account Number CHAR 15
2136 $self->invnum, # 4 | Invoice Number CHAR 15
2137 $lineseq++, # 5 | Line Sequence (sort order) NUM 6
2138 $item->{'description'}, # 6 | Transaction Detail CHAR 100
2139 $item->{'amount'}, # 7 | Amount NUM* 9
2140 '', # 8 | Line Format Control** CHAR 2
2141 '', # 9 | Grouping Code CHAR 2
2142 '', # 10 | User Defined CHAR 15
2145 $detail .= $csv->string. "\n";
2149 } elsif ( lc($opt{'format'}) eq 'oneline' ) {
2155 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2157 my($pkg, $setup, $recur, $sdate, $edate);
2158 if ( $cust_bill_pkg->pkgnum ) {
2160 ($pkg, $setup, $recur, $sdate, $edate) = (
2161 $cust_bill_pkg->part_pkg->pkg,
2162 ( $cust_bill_pkg->setup != 0
2163 ? sprintf("%.2f", $cust_bill_pkg->setup )
2165 ( $cust_bill_pkg->recur != 0
2166 ? sprintf("%.2f", $cust_bill_pkg->recur )
2168 ( $cust_bill_pkg->sdate
2169 ? time2str("%x", $cust_bill_pkg->sdate)
2171 ($cust_bill_pkg->edate
2172 ?time2str("%x", $cust_bill_pkg->edate)
2176 } else { #pkgnum tax
2177 next unless $cust_bill_pkg->setup != 0;
2178 $pkg = $cust_bill_pkg->desc;
2179 $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
2180 ( $sdate, $edate ) = ( '', '' );
2186 ( map { '' } (1..11) ),
2187 ($pkg, $setup, $recur, $sdate, $edate)
2188 ) or die "can't create csv";
2190 $detail .= $csv->string. "\n";
2196 ( $header, $detail );
2202 Pays this invoice with a compliemntary payment. If there is an error,
2203 returns the error, otherwise returns false.
2209 my $cust_pay = new FS::cust_pay ( {
2210 'invnum' => $self->invnum,
2211 'paid' => $self->owed,
2214 'payinfo' => $self->cust_main->payinfo,
2222 Attempts to pay this invoice with a credit card payment via a
2223 Business::OnlinePayment realtime gateway. See
2224 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2225 for supported processors.
2231 $self->realtime_bop( 'CC', @_ );
2236 Attempts to pay this invoice with an electronic check (ACH) payment via a
2237 Business::OnlinePayment realtime gateway. See
2238 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2239 for supported processors.
2245 $self->realtime_bop( 'ECHECK', @_ );
2250 Attempts to pay this invoice with phone bill (LEC) payment via a
2251 Business::OnlinePayment realtime gateway. See
2252 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2253 for supported processors.
2259 $self->realtime_bop( 'LEC', @_ );
2263 my( $self, $method ) = (shift,shift);
2264 my $conf = $self->conf;
2267 my $cust_main = $self->cust_main;
2268 my $balance = $cust_main->balance;
2269 my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
2270 $amount = sprintf("%.2f", $amount);
2271 return "not run (balance $balance)" unless $amount > 0;
2273 my $description = 'Internet Services';
2274 if ( $conf->exists('business-onlinepayment-description') ) {
2275 my $dtempl = $conf->config('business-onlinepayment-description');
2277 my $agent_obj = $cust_main->agent
2278 or die "can't retreive agent for $cust_main (agentnum ".
2279 $cust_main->agentnum. ")";
2280 my $agent = $agent_obj->agent;
2281 my $pkgs = join(', ',
2282 map { $_->part_pkg->pkg }
2283 grep { $_->pkgnum } $self->cust_bill_pkg
2285 $description = eval qq("$dtempl");
2288 $cust_main->realtime_bop($method, $amount,
2289 'description' => $description,
2290 'invnum' => $self->invnum,
2291 #this didn't do what we want, it just calls apply_payments_and_credits
2293 'apply_to_invoice' => 1,
2296 #this changes application behavior: auto payments
2297 #triggered against a specific invoice are now applied
2298 #to that invoice instead of oldest open.
2304 =item batch_card OPTION => VALUE...
2306 Adds a payment for this invoice to the pending credit card batch (see
2307 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2308 runs the payment using a realtime gateway.
2313 my ($self, %options) = @_;
2314 my $cust_main = $self->cust_main;
2316 $options{invnum} = $self->invnum;
2318 $cust_main->batch_card(%options);
2321 sub _agent_template {
2323 $self->cust_main->agent_template;
2326 sub _agent_invoice_from {
2328 $self->cust_main->agent_invoice_from;
2331 =item invoice_barcode DIR_OR_FALSE
2333 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
2334 it is taken as the temp directory where the PNG file will be generated and the
2335 PNG file name is returned. Otherwise, the PNG image itself is returned.
2339 sub invoice_barcode {
2340 my ($self, $dir) = (shift,shift);
2342 my $gdbar = new GD::Barcode('Code39',$self->invnum);
2343 die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
2344 my $gd = $gdbar->plot(Height => 30);
2347 my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
2351 ) or die "can't open temp file: $!\n";
2352 print $bh $gd->png or die "cannot write barcode to file: $!\n";
2353 my $png_file = $bh->filename;
2360 =item invnum_date_pretty
2362 Returns a string with the invoice number and date, for example:
2363 "Invoice #54 (3/20/2008)"
2367 sub invnum_date_pretty {
2369 $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
2372 #sub _items_extra_usage_sections {
2374 # my $escape = shift;
2376 # my %sections = ();
2378 # my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
2379 # foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2381 # next unless $cust_bill_pkg->pkgnum > 0;
2383 # foreach my $section ( keys %usage_class ) {
2385 # my $usage = $cust_bill_pkg->usage($section);
2387 # next unless $usage && $usage > 0;
2389 # $sections{$section} ||= 0;
2390 # $sections{$section} += $usage;
2396 # map { { 'description' => &{$escape}($_),
2397 # 'subtotal' => $sections{$_},
2398 # 'summarized' => '',
2399 # 'tax_section' => '',
2402 # sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
2406 sub _items_extra_usage_sections {
2408 my $conf = $self->conf;
2416 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2418 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2419 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2420 next unless $cust_bill_pkg->pkgnum > 0;
2422 foreach my $classnum ( keys %usage_class ) {
2423 my $section = $usage_class{$classnum}->classname;
2424 $classnums{$section} = $classnum;
2426 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
2427 my $amount = $detail->amount;
2428 next unless $amount && $amount > 0;
2430 $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
2431 $sections{$section}{amount} += $amount; #subtotal
2432 $sections{$section}{calls}++;
2433 $sections{$section}{duration} += $detail->duration;
2435 my $desc = $detail->regionname;
2436 my $description = $desc;
2437 $description = substr($desc, 0, $maxlength). '...'
2438 if $format eq 'latex' && length($desc) > $maxlength;
2440 $lines{$section}{$desc} ||= {
2441 description => &{$escape}($description),
2442 #pkgpart => $part_pkg->pkgpart,
2443 pkgnum => $cust_bill_pkg->pkgnum,
2448 #unit_amount => $cust_bill_pkg->unitrecur,
2449 quantity => $cust_bill_pkg->quantity,
2450 product_code => 'N/A',
2451 ext_description => [],
2454 $lines{$section}{$desc}{amount} += $amount;
2455 $lines{$section}{$desc}{calls}++;
2456 $lines{$section}{$desc}{duration} += $detail->duration;
2462 my %sectionmap = ();
2463 foreach (keys %sections) {
2464 my $usage_class = $usage_class{$classnums{$_}};
2465 $sectionmap{$_} = { 'description' => &{$escape}($_),
2466 'amount' => $sections{$_}{amount}, #subtotal
2467 'calls' => $sections{$_}{calls},
2468 'duration' => $sections{$_}{duration},
2470 'tax_section' => '',
2471 'sort_weight' => $usage_class->weight,
2472 ( $usage_class->format
2473 ? ( map { $_ => $usage_class->$_($format) }
2474 qw( description_generator header_generator total_generator total_line_generator )
2481 my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
2485 foreach my $section ( keys %lines ) {
2486 foreach my $line ( keys %{$lines{$section}} ) {
2487 my $l = $lines{$section}{$line};
2488 $l->{section} = $sectionmap{$section};
2489 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2490 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2495 return(\@sections, \@lines);
2501 my $end = $self->_date;
2503 # start at date of previous invoice + 1 second or 0 if no previous invoice
2504 my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
2505 $start = 0 if !$start;
2508 my $cust_main = $self->cust_main;
2509 my @pkgs = $cust_main->all_pkgs;
2510 my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
2513 foreach my $pkg ( @pkgs ) {
2514 my @h_cust_svc = $pkg->h_cust_svc($end);
2515 foreach my $h_cust_svc ( @h_cust_svc ) {
2516 next if grep {$_ eq $h_cust_svc->svcnum} @seen;
2517 next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
2519 my $inserted = $h_cust_svc->date_inserted;
2520 my $deleted = $h_cust_svc->date_deleted;
2521 my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
2523 $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted;
2525 # DID either activated or ported in; cannot be both for same DID simultaneously
2526 if ($inserted >= $start && $inserted <= $end && $phone_inserted
2527 && (!$phone_inserted->lnp_status
2528 || $phone_inserted->lnp_status eq ''
2529 || $phone_inserted->lnp_status eq 'native')) {
2532 else { # this one not so clean, should probably move to (h_)svc_phone
2533 my $phone_portedin = qsearchs( 'h_svc_phone',
2534 { 'svcnum' => $h_cust_svc->svcnum,
2535 'lnp_status' => 'portedin' },
2536 FS::h_svc_phone->sql_h_searchs($end),
2538 $num_portedin++ if $phone_portedin;
2541 # DID either deactivated or ported out; cannot be both for same DID simultaneously
2542 if($deleted >= $start && $deleted <= $end && $phone_deleted
2543 && (!$phone_deleted->lnp_status
2544 || $phone_deleted->lnp_status ne 'portingout')) {
2547 elsif($deleted >= $start && $deleted <= $end && $phone_deleted
2548 && $phone_deleted->lnp_status
2549 && $phone_deleted->lnp_status eq 'portingout') {
2553 # increment usage minutes
2554 if ( $phone_inserted ) {
2555 my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
2556 $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
2559 warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
2562 # don't look at this service again
2563 push @seen, $h_cust_svc->svcnum;
2567 $minutes = sprintf("%d", $minutes);
2568 ("Activated: $num_activated Ported-In: $num_portedin Deactivated: "
2569 . "$num_deactivated Ported-Out: $num_portedout ",
2570 "Total Minutes: $minutes");
2573 sub _items_accountcode_cdr {
2578 my $section = { 'amount' => 0,
2581 'sort_weight' => '',
2583 'description' => 'Usage by Account Code',
2589 my %accountcodes = ();
2591 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2592 next unless $cust_bill_pkg->pkgnum > 0;
2594 my @header = $cust_bill_pkg->details_header;
2595 next unless scalar(@header);
2596 $section->{'header'} = join(',',@header);
2598 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2600 $section->{'header'} = $detail->formatted('format' => $format)
2601 if($detail->detail eq $section->{'header'});
2603 my $accountcode = $detail->accountcode;
2604 next unless $accountcode;
2606 my $amount = $detail->amount;
2607 next unless $amount && $amount > 0;
2609 $accountcodes{$accountcode} ||= {
2610 description => $accountcode,
2617 product_code => 'N/A',
2618 section => $section,
2619 ext_description => [ $section->{'header'} ],
2623 $section->{'amount'} += $amount;
2624 $accountcodes{$accountcode}{'amount'} += $amount;
2625 $accountcodes{$accountcode}{calls}++;
2626 $accountcodes{$accountcode}{duration} += $detail->duration;
2627 push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
2631 foreach my $l ( values %accountcodes ) {
2632 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2633 my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
2634 foreach my $sorted_detail ( @sorted_detail ) {
2635 push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
2637 delete $l->{detail_temp};
2641 my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
2643 return ($section,\@sorted_lines);
2646 sub _items_svc_phone_sections {
2648 my $conf = $self->conf;
2656 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2658 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2659 $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
2661 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2662 next unless $cust_bill_pkg->pkgnum > 0;
2664 my @header = $cust_bill_pkg->details_header;
2665 next unless scalar(@header);
2667 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2669 my $phonenum = $detail->phonenum;
2670 next unless $phonenum;
2672 my $amount = $detail->amount;
2673 next unless $amount && $amount > 0;
2675 $sections{$phonenum} ||= { 'amount' => 0,
2678 'sort_weight' => -1,
2679 'phonenum' => $phonenum,
2681 $sections{$phonenum}{amount} += $amount; #subtotal
2682 $sections{$phonenum}{calls}++;
2683 $sections{$phonenum}{duration} += $detail->duration;
2685 my $desc = $detail->regionname;
2686 my $description = $desc;
2687 $description = substr($desc, 0, $maxlength). '...'
2688 if $format eq 'latex' && length($desc) > $maxlength;
2690 $lines{$phonenum}{$desc} ||= {
2691 description => &{$escape}($description),
2692 #pkgpart => $part_pkg->pkgpart,
2700 product_code => 'N/A',
2701 ext_description => [],
2704 $lines{$phonenum}{$desc}{amount} += $amount;
2705 $lines{$phonenum}{$desc}{calls}++;
2706 $lines{$phonenum}{$desc}{duration} += $detail->duration;
2708 my $line = $usage_class{$detail->classnum}->classname;
2709 $sections{"$phonenum $line"} ||=
2713 'sort_weight' => $usage_class{$detail->classnum}->weight,
2714 'phonenum' => $phonenum,
2715 'header' => [ @header ],
2717 $sections{"$phonenum $line"}{amount} += $amount; #subtotal
2718 $sections{"$phonenum $line"}{calls}++;
2719 $sections{"$phonenum $line"}{duration} += $detail->duration;
2721 $lines{"$phonenum $line"}{$desc} ||= {
2722 description => &{$escape}($description),
2723 #pkgpart => $part_pkg->pkgpart,
2731 product_code => 'N/A',
2732 ext_description => [],
2735 $lines{"$phonenum $line"}{$desc}{amount} += $amount;
2736 $lines{"$phonenum $line"}{$desc}{calls}++;
2737 $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
2738 push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
2739 $detail->formatted('format' => $format);
2744 my %sectionmap = ();
2745 my $simple = new FS::usage_class { format => 'simple' }; #bleh
2746 foreach ( keys %sections ) {
2747 my @header = @{ $sections{$_}{header} || [] };
2749 new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
2750 my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
2751 my $usage_class = $summary ? $simple : $usage_simple;
2752 my $ending = $summary ? ' usage charges' : '';
2755 $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
2757 $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
2758 'amount' => $sections{$_}{amount}, #subtotal
2759 'calls' => $sections{$_}{calls},
2760 'duration' => $sections{$_}{duration},
2762 'tax_section' => '',
2763 'phonenum' => $sections{$_}{phonenum},
2764 'sort_weight' => $sections{$_}{sort_weight},
2765 'post_total' => $summary, #inspire pagebreak
2767 ( map { $_ => $usage_class->$_($format, %gen_opt) }
2768 qw( description_generator
2771 total_line_generator
2778 my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
2779 $a->{sort_weight} <=> $b->{sort_weight}
2784 foreach my $section ( keys %lines ) {
2785 foreach my $line ( keys %{$lines{$section}} ) {
2786 my $l = $lines{$section}{$line};
2787 $l->{section} = $sectionmap{$section};
2788 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2789 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2794 if($conf->exists('phone_usage_class_summary')) {
2795 # this only works with Latex
2799 # after this, we'll have only two sections per DID:
2800 # Calls Summary and Calls Detail
2801 foreach my $section ( @sections ) {
2802 if($section->{'post_total'}) {
2803 $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
2804 $section->{'total_line_generator'} = sub { '' };
2805 $section->{'total_generator'} = sub { '' };
2806 $section->{'header_generator'} = sub { '' };
2807 $section->{'description_generator'} = '';
2808 push @newsections, $section;
2809 my %calls_detail = %$section;
2810 $calls_detail{'post_total'} = '';
2811 $calls_detail{'sort_weight'} = '';
2812 $calls_detail{'description_generator'} = sub { '' };
2813 $calls_detail{'header_generator'} = sub {
2814 return ' & Date/Time & Called Number & Duration & Price'
2815 if $format eq 'latex';
2818 $calls_detail{'description'} = 'Calls Detail: '
2819 . $section->{'phonenum'};
2820 push @newsections, \%calls_detail;
2824 # after this, each usage class is collapsed/summarized into a single
2825 # line under the Calls Summary section
2826 foreach my $newsection ( @newsections ) {
2827 if($newsection->{'post_total'}) { # this means Calls Summary
2828 foreach my $section ( @sections ) {
2829 next unless ($section->{'phonenum'} eq $newsection->{'phonenum'}
2830 && !$section->{'post_total'});
2831 my $newdesc = $section->{'description'};
2832 my $tn = $section->{'phonenum'};
2833 $newdesc =~ s/$tn//g;
2834 my $line = { ext_description => [],
2838 calls => $section->{'calls'},
2839 section => $newsection,
2840 duration => $section->{'duration'},
2841 description => $newdesc,
2842 amount => sprintf("%.2f",$section->{'amount'}),
2843 product_code => 'N/A',
2845 push @newlines, $line;
2850 # after this, Calls Details is populated with all CDRs
2851 foreach my $newsection ( @newsections ) {
2852 if(!$newsection->{'post_total'}) { # this means Calls Details
2853 foreach my $line ( @lines ) {
2854 next unless (scalar(@{$line->{'ext_description'}}) &&
2855 $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
2857 my @extdesc = @{$line->{'ext_description'}};
2859 foreach my $extdesc ( @extdesc ) {
2860 $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
2861 push @newextdesc, $extdesc;
2863 $line->{'ext_description'} = \@newextdesc;
2864 $line->{'section'} = $newsection;
2865 push @newlines, $line;
2870 return(\@newsections, \@newlines);
2873 return(\@sections, \@lines);
2877 sub _items_previous {
2879 my $conf = $self->conf;
2880 my $cust_main = $self->cust_main;
2881 my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
2883 foreach ( @pr_cust_bill ) {
2884 my $date = $conf->exists('invoice_show_prior_due_date')
2885 ? 'due '. $_->due_date2str($date_format)
2886 : time2str($date_format, $_->_date);
2888 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
2889 #'pkgpart' => 'N/A',
2891 'amount' => sprintf("%.2f", $_->owed),
2897 # 'description' => 'Previous Balance',
2898 # #'pkgpart' => 'N/A',
2899 # 'pkgnum' => 'N/A',
2900 # 'amount' => sprintf("%10.2f", $pr_total ),
2901 # 'ext_description' => [ map {
2902 # "Invoice ". $_->invnum.
2903 # " (". time2str("%x",$_->_date). ") ".
2904 # sprintf("%10.2f", $_->owed)
2905 # } @pr_cust_bill ],
2910 sub _items_credits {
2911 my( $self, %opt ) = @_;
2912 my $trim_len = $opt{'trim_len'} || 60;
2916 foreach ( $self->cust_credited ) {
2918 #something more elaborate if $_->amount ne $_->cust_credit->credited ?
2920 my $reason = substr($_->cust_credit->reason, 0, $trim_len);
2921 $reason .= '...' if length($reason) < length($_->cust_credit->reason);
2922 $reason = " ($reason) " if $reason;
2925 #'description' => 'Credit ref\#'. $_->crednum.
2926 # " (". time2str("%x",$_->cust_credit->_date) .")".
2928 'description' => $self->mt('Credit applied').' '.
2929 time2str($date_format,$_->cust_credit->_date). $reason,
2930 'amount' => sprintf("%.2f",$_->amount),
2938 sub _items_payments {
2942 #get & print payments
2943 foreach ( $self->cust_bill_pay ) {
2945 #something more elaborate if $_->amount ne ->cust_pay->paid ?
2948 'description' => $self->mt('Payment received').' '.
2949 time2str($date_format,$_->cust_pay->_date ),
2950 'amount' => sprintf("%.2f", $_->amount )
2958 =item call_details [ OPTION => VALUE ... ]
2960 Returns an array of CSV strings representing the call details for this invoice
2961 The only option available is the boolean prepend_billed_number
2966 my ($self, %opt) = @_;
2968 my $format_function = sub { shift };
2970 if ($opt{prepend_billed_number}) {
2971 $format_function = sub {
2975 $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
2980 my @details = map { $_->details( 'format_function' => $format_function,
2981 'escape_function' => sub{ return() },
2985 $self->cust_bill_pkg;
2986 my $header = $details[0];
2987 ( $header, grep { $_ ne $header } @details );
2997 =item process_reprint
3001 sub process_reprint {
3002 process_re_X('print', @_);
3005 =item process_reemail
3009 sub process_reemail {
3010 process_re_X('email', @_);
3018 process_re_X('fax', @_);
3026 process_re_X('ftp', @_);
3033 sub process_respool {
3034 process_re_X('spool', @_);
3037 use Storable qw(thaw);
3041 my( $method, $job ) = ( shift, shift );
3042 warn "$me process_re_X $method for job $job\n" if $DEBUG;
3044 my $param = thaw(decode_base64(shift));
3045 warn Dumper($param) if $DEBUG;
3056 # spool_invoice ftp_invoice fax_invoice print_invoice
3057 my($method, $job, %param ) = @_;
3059 warn "re_X $method for job $job with param:\n".
3060 join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
3063 #some false laziness w/search/cust_bill.html
3065 my $orderby = 'ORDER BY cust_bill._date';
3067 my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
3069 my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
3071 my @cust_bill = qsearch( {
3072 #'select' => "cust_bill.*",
3073 'table' => 'cust_bill',
3074 'addl_from' => $addl_from,
3076 'extra_sql' => $extra_sql,
3077 'order_by' => $orderby,
3081 $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
3083 warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
3086 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
3087 foreach my $cust_bill ( @cust_bill ) {
3088 $cust_bill->$method();
3090 if ( $job ) { #progressbar foo
3092 if ( time - $min_sec > $last ) {
3093 my $error = $job->update_statustext(
3094 int( 100 * $num / scalar(@cust_bill) )
3096 die $error if $error;
3107 =head1 CLASS METHODS
3113 Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
3118 my ($class, $start, $end) = @_;
3120 $class->paid_sql($start, $end). ' - '.
3121 $class->credited_sql($start, $end);
3126 Returns an SQL fragment to retreive the net amount (charged minus credited).
3131 my ($class, $start, $end) = @_;
3132 'charged - '. $class->credited_sql($start, $end);
3137 Returns an SQL fragment to retreive the amount paid against this invoice.
3142 my ($class, $start, $end) = @_;
3143 $start &&= "AND cust_bill_pay._date <= $start";
3144 $end &&= "AND cust_bill_pay._date > $end";
3145 $start = '' unless defined($start);
3146 $end = '' unless defined($end);
3147 "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
3148 WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )";
3153 Returns an SQL fragment to retreive the amount credited against this invoice.
3158 my ($class, $start, $end) = @_;
3159 $start &&= "AND cust_credit_bill._date <= $start";
3160 $end &&= "AND cust_credit_bill._date > $end";
3161 $start = '' unless defined($start);
3162 $end = '' unless defined($end);
3163 "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
3164 WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
3169 Returns an SQL fragment to retrieve the due date of an invoice.
3170 Currently only supported on PostgreSQL.
3175 my $conf = new FS::Conf;
3179 cust_bill.invoice_terms,
3180 cust_main.invoice_terms,
3181 \''.($conf->config('invoice_default_terms') || '').'\'
3182 ), E\'Net (\\\\d+)\'
3184 ) * 86400 + cust_bill._date'
3187 =item search_sql_where HASHREF
3189 Class method which returns an SQL WHERE fragment to search for parameters
3190 specified in HASHREF. Valid parameters are
3196 List reference of start date, end date, as UNIX timestamps.
3206 List reference of charged limits (exclusive).
3210 List reference of charged limits (exclusive).
3214 flag, return open invoices only
3218 flag, return net invoices only
3222 =item newest_percust
3226 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
3230 sub search_sql_where {
3231 my($class, $param) = @_;
3233 warn "$me search_sql_where called with params: \n".
3234 join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
3240 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
3241 push @search, "cust_main.agentnum = $1";
3245 if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
3246 push @search, "cust_main.refnum = $1";
3250 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
3251 push @search, "cust_bill.custnum = $1";
3255 if ( $param->{_date} ) {
3256 my($beginning, $ending) = @{$param->{_date}};
3258 push @search, "cust_bill._date >= $beginning",
3259 "cust_bill._date < $ending";
3263 if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
3264 push @search, "cust_bill.invnum >= $1";
3266 if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
3267 push @search, "cust_bill.invnum <= $1";
3271 if ( $param->{charged} ) {
3272 my @charged = ref($param->{charged})
3273 ? @{ $param->{charged} }
3274 : ($param->{charged});
3276 push @search, map { s/^charged/cust_bill.charged/; $_; }
3280 my $owed_sql = FS::cust_bill->owed_sql;
3283 if ( $param->{owed} ) {
3284 my @owed = ref($param->{owed})
3285 ? @{ $param->{owed} }
3287 push @search, map { s/^owed/$owed_sql/; $_; }
3292 push @search, "0 != $owed_sql"
3293 if $param->{'open'};
3294 push @search, '0 != '. FS::cust_bill->net_sql
3298 push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
3299 if $param->{'days'};
3302 if ( $param->{'newest_percust'} ) {
3304 #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
3305 #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
3307 my @newest_where = map { my $x = $_;
3308 $x =~ s/\bcust_bill\./newest_cust_bill./g;
3311 grep ! /^cust_main./, @search;
3312 my $newest_where = scalar(@newest_where)
3313 ? ' AND '. join(' AND ', @newest_where)
3317 push @search, "cust_bill._date = (
3318 SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
3319 WHERE newest_cust_bill.custnum = cust_bill.custnum
3325 #promised_date - also has an option to accept nulls
3326 if ( $param->{promised_date} ) {
3327 my($beginning, $ending, $null) = @{$param->{promised_date}};
3329 push @search, "(( cust_bill.promised_date >= $beginning AND ".
3330 "cust_bill.promised_date < $ending )" .
3331 ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
3334 #agent virtualization
3335 my $curuser = $FS::CurrentUser::CurrentUser;
3336 if ( $curuser->username eq 'fs_queue'
3337 && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
3339 my $newuser = qsearchs('access_user', {
3340 'username' => $username,
3344 $curuser = $newuser;
3346 warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
3349 push @search, $curuser->agentnums_sql;
3351 join(' AND ', @search );
3363 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
3364 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base