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 sum);
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 billing_balance - the customer's balance at the time the invoice was
114 generated (not including charges on this invoice)
116 =item previous_balance - the billing_balance of this customer's previous
117 invoice plus the charges on that invoice
125 =item printed - deprecated
133 =item closed - books closed flag, empty or `Y'
135 =item statementnum - invoice aggregation (see L<FS::cust_statement>)
137 =item agent_invid - legacy invoice number
139 =item promised_date - customer promised payment date, for collection
149 Creates a new invoice. To add the invoice to the database, see L<"insert">.
150 Invoices are normally created by calling the bill method of a customer object
151 (see L<FS::cust_main>).
155 sub table { 'cust_bill'; }
156 sub notice_name { 'Invoice'; }
158 sub cust_linked { $_[0]->cust_main_custnum; }
159 sub cust_unlinked_msg {
161 "WARNING: can't find cust_main.custnum ". $self->custnum.
162 ' (cust_bill.invnum '. $self->invnum. ')';
167 Adds this invoice to the database ("Posts" the invoice). If there is an error,
168 returns the error, otherwise returns false.
174 warn "$me insert called\n" if $DEBUG;
176 local $SIG{HUP} = 'IGNORE';
177 local $SIG{INT} = 'IGNORE';
178 local $SIG{QUIT} = 'IGNORE';
179 local $SIG{TERM} = 'IGNORE';
180 local $SIG{TSTP} = 'IGNORE';
181 local $SIG{PIPE} = 'IGNORE';
183 my $oldAutoCommit = $FS::UID::AutoCommit;
184 local $FS::UID::AutoCommit = 0;
187 my $error = $self->SUPER::insert;
189 $dbh->rollback if $oldAutoCommit;
193 if ( $self->get('cust_bill_pkg') ) {
194 foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
195 $cust_bill_pkg->invnum($self->invnum);
196 my $error = $cust_bill_pkg->insert;
198 $dbh->rollback if $oldAutoCommit;
199 return "can't create invoice line item: $error";
204 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
211 Voids this invoice: deletes the invoice and adds a record of the voided invoice
212 to the FS::cust_bill_void table (and related tables starting from
213 FS::cust_bill_pkg_void).
219 my $reason = scalar(@_) ? shift : '';
221 local $SIG{HUP} = 'IGNORE';
222 local $SIG{INT} = 'IGNORE';
223 local $SIG{QUIT} = 'IGNORE';
224 local $SIG{TERM} = 'IGNORE';
225 local $SIG{TSTP} = 'IGNORE';
226 local $SIG{PIPE} = 'IGNORE';
228 my $oldAutoCommit = $FS::UID::AutoCommit;
229 local $FS::UID::AutoCommit = 0;
232 my $cust_bill_void = new FS::cust_bill_void ( {
233 map { $_ => $self->get($_) } $self->fields
235 $cust_bill_void->reason($reason);
236 my $error = $cust_bill_void->insert;
238 $dbh->rollback if $oldAutoCommit;
242 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
243 my $error = $cust_bill_pkg->void($reason);
245 $dbh->rollback if $oldAutoCommit;
250 $error = $self->delete;
252 $dbh->rollback if $oldAutoCommit;
256 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
264 This method now works but you probably shouldn't use it. Instead, apply a
265 credit against the invoice, or use the new void method.
267 Using this method to delete invoices outright is really, really bad. There
268 would be no record you ever posted this invoice, and there are no check to
269 make sure charged = 0 or that there are no associated cust_bill_pkg records.
271 Really, don't use it.
277 return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
279 local $SIG{HUP} = 'IGNORE';
280 local $SIG{INT} = 'IGNORE';
281 local $SIG{QUIT} = 'IGNORE';
282 local $SIG{TERM} = 'IGNORE';
283 local $SIG{TSTP} = 'IGNORE';
284 local $SIG{PIPE} = 'IGNORE';
286 my $oldAutoCommit = $FS::UID::AutoCommit;
287 local $FS::UID::AutoCommit = 0;
290 foreach my $table (qw(
301 foreach my $linked ( $self->$table() ) {
302 my $error = $linked->delete;
304 $dbh->rollback if $oldAutoCommit;
311 my $error = $self->SUPER::delete(@_);
313 $dbh->rollback if $oldAutoCommit;
317 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
323 =item replace [ OLD_RECORD ]
325 You can, but probably shouldn't modify invoices...
327 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
328 supplied, replaces this record. If there is an error, returns the error,
329 otherwise returns false.
333 #replace can be inherited from Record.pm
335 # replace_check is now the preferred way to #implement replace data checks
336 # (so $object->replace() works without an argument)
339 my( $new, $old ) = ( shift, shift );
340 return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
341 #return "Can't change _date!" unless $old->_date eq $new->_date;
342 return "Can't change _date" unless $old->_date == $new->_date;
343 return "Can't change charged" unless $old->charged == $new->charged
344 || $old->charged == 0
345 || $new->{'Hash'}{'cc_surcharge_replace_hack'};
351 =item add_cc_surcharge
357 sub add_cc_surcharge {
358 my ($self, $pkgnum, $amount) = (shift, shift, shift);
361 my $cust_bill_pkg = new FS::cust_bill_pkg({
362 'invnum' => $self->invnum,
366 $error = $cust_bill_pkg->insert;
367 return $error if $error;
369 $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
370 $self->charged($self->charged+$amount);
371 $error = $self->replace;
372 return $error if $error;
374 $self->apply_payments_and_credits;
380 Checks all fields to make sure this is a valid invoice. If there is an error,
381 returns the error, otherwise returns false. Called by the insert and replace
390 $self->ut_numbern('invnum')
391 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
392 || $self->ut_numbern('_date')
393 || $self->ut_money('charged')
394 || $self->ut_numbern('printed')
395 || $self->ut_enum('closed', [ '', 'Y' ])
396 || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
397 || $self->ut_numbern('agent_invid') #varchar?
399 return $error if $error;
401 $self->_date(time) unless $self->_date;
403 $self->printed(0) if $self->printed eq '';
410 Returns the displayed invoice number for this invoice: agent_invid if
411 cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
417 my $conf = $self->conf;
418 if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
419 return $self->agent_invid;
421 return $self->invnum;
427 Returns a list consisting of the total previous balance for this customer,
428 followed by the previous outstanding invoices (as FS::cust_bill objects also).
435 my @cust_bill = sort { $a->_date <=> $b->_date }
436 grep { $_->owed != 0 }
437 qsearch( 'cust_bill', { 'custnum' => $self->custnum,
438 #'_date' => { op=>'<', value=>$self->_date },
439 'invnum' => { op=>'<', value=>$self->invnum },
442 foreach ( @cust_bill ) { $total += $_->owed; }
446 =item enable_previous
448 Whether to show the 'Previous Charges' section when printing this invoice.
449 The negation of the 'disable_previous_balance' config setting.
453 sub enable_previous {
455 my $agentnum = $self->cust_main->agentnum;
456 !$self->conf->exists('disable_previous_balance', $agentnum);
461 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
468 { 'table' => 'cust_bill_pkg',
469 'hashref' => { 'invnum' => $self->invnum },
470 'order_by' => 'ORDER BY billpkgnum',
475 =item cust_bill_pkg_pkgnum PKGNUM
477 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
482 sub cust_bill_pkg_pkgnum {
483 my( $self, $pkgnum ) = @_;
485 { 'table' => 'cust_bill_pkg',
486 'hashref' => { 'invnum' => $self->invnum,
489 'order_by' => 'ORDER BY billpkgnum',
496 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
503 my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
504 $self->cust_bill_pkg;
506 grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
511 Returns true if any of the packages (or their definitions) corresponding to the
512 line items for this invoice have the no_auto flag set.
518 grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
521 =item open_cust_bill_pkg
523 Returns the open line items for this invoice.
525 Note that cust_bill_pkg with both setup and recur fees are returned as two
526 separate line items, each with only one fee.
530 # modeled after cust_main::open_cust_bill
531 sub open_cust_bill_pkg {
534 # grep { $_->owed > 0 } $self->cust_bill_pkg
536 my %other = ( 'recur' => 'setup',
537 'setup' => 'recur', );
539 foreach my $field ( qw( recur setup )) {
540 push @open, map { $_->set( $other{$field}, 0 ); $_; }
541 grep { $_->owed($field) > 0 }
542 $self->cust_bill_pkg;
548 =item cust_bill_event
550 Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
554 sub cust_bill_event {
556 qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
559 =item num_cust_bill_event
561 Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
565 sub num_cust_bill_event {
568 "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
569 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
570 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
571 $sth->fetchrow_arrayref->[0];
576 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
580 #false laziness w/cust_pkg.pm
584 'table' => 'cust_event',
585 'addl_from' => 'JOIN part_event USING ( eventpart )',
586 'hashref' => { 'tablenum' => $self->invnum },
587 'extra_sql' => " AND eventtable = 'cust_bill' ",
593 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
597 #false laziness w/cust_pkg.pm
601 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
602 " WHERE tablenum = ? AND eventtable = 'cust_bill'";
603 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
604 $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
605 $sth->fetchrow_arrayref->[0];
610 Returns the customer (see L<FS::cust_main>) for this invoice.
616 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
619 =item cust_suspend_if_balance_over AMOUNT
621 Suspends the customer associated with this invoice if the total amount owed on
622 this invoice and all older invoices is greater than the specified amount.
624 Returns a list: an empty list on success or a list of errors.
628 sub cust_suspend_if_balance_over {
629 my( $self, $amount ) = ( shift, shift );
630 my $cust_main = $self->cust_main;
631 if ( $cust_main->total_owed_date($self->_date) < $amount ) {
634 $cust_main->suspend(@_);
640 Depreciated. See the cust_credited method.
642 #Returns a list consisting of the total previous credited (see
643 #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
644 #outstanding credits (FS::cust_credit objects).
650 croak "FS::cust_bill->cust_credit depreciated; see ".
651 "FS::cust_bill->cust_credit_bill";
654 #my @cust_credit = sort { $a->_date <=> $b->_date }
655 # grep { $_->credited != 0 && $_->_date < $self->_date }
656 # qsearch('cust_credit', { 'custnum' => $self->custnum } )
658 #foreach (@cust_credit) { $total += $_->credited; }
659 #$total, @cust_credit;
664 Depreciated. See the cust_bill_pay method.
666 #Returns all payments (see L<FS::cust_pay>) for this invoice.
672 croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
674 #sort { $a->_date <=> $b->_date }
675 # qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
681 qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
684 sub cust_bill_pay_batch {
686 qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
691 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
697 map { $_ } #return $self->num_cust_bill_pay unless wantarray;
698 sort { $a->_date <=> $b->_date }
699 qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
704 =item cust_credit_bill
706 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
712 map { $_ } #return $self->num_cust_credit_bill unless wantarray;
713 sort { $a->_date <=> $b->_date }
714 qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
718 sub cust_credit_bill {
719 shift->cust_credited(@_);
722 #=item cust_bill_pay_pkgnum PKGNUM
724 #Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
725 #with matching pkgnum.
729 #sub cust_bill_pay_pkgnum {
730 # my( $self, $pkgnum ) = @_;
731 # map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
732 # sort { $a->_date <=> $b->_date }
733 # qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
734 # 'pkgnum' => $pkgnum,
739 =item cust_bill_pay_pkg PKGNUM
741 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
742 applied against the matching pkgnum.
746 sub cust_bill_pay_pkg {
747 my( $self, $pkgnum ) = @_;
750 'select' => 'cust_bill_pay_pkg.*',
751 'table' => 'cust_bill_pay_pkg',
752 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
753 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
754 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
755 " AND cust_bill_pkg.pkgnum = $pkgnum",
760 #=item cust_credited_pkgnum PKGNUM
762 #=item cust_credit_bill_pkgnum PKGNUM
764 #Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
765 #with matching pkgnum.
769 #sub cust_credited_pkgnum {
770 # my( $self, $pkgnum ) = @_;
771 # map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
772 # sort { $a->_date <=> $b->_date }
773 # qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
774 # 'pkgnum' => $pkgnum,
779 #sub cust_credit_bill_pkgnum {
780 # shift->cust_credited_pkgnum(@_);
783 =item cust_credit_bill_pkg PKGNUM
785 Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
786 applied against the matching pkgnum.
790 sub cust_credit_bill_pkg {
791 my( $self, $pkgnum ) = @_;
794 'select' => 'cust_credit_bill_pkg.*',
795 'table' => 'cust_credit_bill_pkg',
796 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
797 ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
798 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
799 " AND cust_bill_pkg.pkgnum = $pkgnum",
804 =item cust_bill_batch
806 Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
810 sub cust_bill_batch {
812 qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
817 Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
818 hash keyed by term length.
824 FS::discount_plan->all($self);
829 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
836 my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
838 foreach (@taxlines) { $total += $_->setup; }
844 Returns the amount owed (still outstanding) on this invoice, which is charged
845 minus all payment applications (see L<FS::cust_bill_pay>) and credit
846 applications (see L<FS::cust_credit_bill>).
852 my $balance = $self->charged;
853 $balance -= $_->amount foreach ( $self->cust_bill_pay );
854 $balance -= $_->amount foreach ( $self->cust_credited );
855 $balance = sprintf( "%.2f", $balance);
856 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
861 my( $self, $pkgnum ) = @_;
863 #my $balance = $self->charged;
865 $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
867 $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum);
868 $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum);
870 $balance = sprintf( "%.2f", $balance);
871 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
877 Returns true if this invoice should be hidden. See the
878 selfservice-hide_invoices-taxclass configuraiton setting.
884 my $conf = $self->conf;
885 my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
887 my @cust_bill_pkg = $self->cust_bill_pkg;
888 my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
889 ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
892 =item apply_payments_and_credits [ OPTION => VALUE ... ]
894 Applies unapplied payments and credits to this invoice.
896 A hash of optional arguments may be passed. Currently "manual" is supported.
897 If true, a payment receipt is sent instead of a statement when
898 'payment_receipt_email' configuration option is set.
900 If there is an error, returns the error, otherwise returns false.
904 sub apply_payments_and_credits {
905 my( $self, %options ) = @_;
906 my $conf = $self->conf;
908 local $SIG{HUP} = 'IGNORE';
909 local $SIG{INT} = 'IGNORE';
910 local $SIG{QUIT} = 'IGNORE';
911 local $SIG{TERM} = 'IGNORE';
912 local $SIG{TSTP} = 'IGNORE';
913 local $SIG{PIPE} = 'IGNORE';
915 my $oldAutoCommit = $FS::UID::AutoCommit;
916 local $FS::UID::AutoCommit = 0;
919 $self->select_for_update; #mutex
921 my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
922 my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
924 if ( $conf->exists('pkg-balances') ) {
925 # limit @payments & @credits to those w/ a pkgnum grepped from $self
926 my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
927 @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
928 @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
931 while ( $self->owed > 0 and ( @payments || @credits ) ) {
934 if ( @payments && @credits ) {
936 #decide which goes first by weight of top (unapplied) line item
938 my @open_lineitems = $self->open_cust_bill_pkg;
941 max( map { $_->part_pkg->pay_weight || 0 }
946 my $max_credit_weight =
947 max( map { $_->part_pkg->credit_weight || 0 }
953 #if both are the same... payments first? it has to be something
954 if ( $max_pay_weight >= $max_credit_weight ) {
960 } elsif ( @payments ) {
962 } elsif ( @credits ) {
965 die "guru meditation #12 and 35";
969 if ( $app eq 'pay' ) {
971 my $payment = shift @payments;
972 $unapp_amount = $payment->unapplied;
973 $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum };
974 $app->pkgnum( $payment->pkgnum )
975 if $conf->exists('pkg-balances') && $payment->pkgnum;
977 } elsif ( $app eq 'credit' ) {
979 my $credit = shift @credits;
980 $unapp_amount = $credit->credited;
981 $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
982 $app->pkgnum( $credit->pkgnum )
983 if $conf->exists('pkg-balances') && $credit->pkgnum;
986 die "guru meditation #12 and 35";
990 if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
991 warn "owed_pkgnum ". $app->pkgnum;
992 $owed = $self->owed_pkgnum($app->pkgnum);
996 next unless $owed > 0;
998 warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
999 $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
1001 $app->invnum( $self->invnum );
1003 my $error = $app->insert(%options);
1005 $dbh->rollback if $oldAutoCommit;
1006 return "Error inserting ". $app->table. " record: $error";
1008 die $error if $error;
1012 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1017 =item generate_email OPTION => VALUE ...
1025 sender address, required
1029 alternate template name, optional
1033 text attachment arrayref, optional
1037 email subject, optional
1041 notice name instead of "Invoice", optional
1045 Returns an argument list to be passed to L<FS::Misc::send_email>.
1051 sub generate_email {
1055 my $conf = $self->conf;
1057 my $me = '[FS::cust_bill::generate_email]';
1060 'from' => $args{'from'},
1061 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
1065 'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
1066 'template' => $args{'template'},
1067 'notice_name' => ( $args{'notice_name'} || 'Invoice' ),
1068 'no_coupon' => $args{'no_coupon'},
1071 my $cust_main = $self->cust_main;
1073 if (ref($args{'to'}) eq 'ARRAY') {
1074 $return{'to'} = $args{'to'};
1076 $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
1077 $cust_main->invoicing_list
1081 if ( $conf->exists('invoice_html') ) {
1083 warn "$me creating HTML/text multipart message"
1086 $return{'nobody'} = 1;
1088 my $alternative = build MIME::Entity
1089 'Type' => 'multipart/alternative',
1090 #'Encoding' => '7bit',
1091 'Disposition' => 'inline'
1095 if ( $conf->exists('invoice_email_pdf')
1096 and scalar($conf->config('invoice_email_pdf_note')) ) {
1098 warn "$me using 'invoice_email_pdf_note' in multipart message"
1100 $data = [ map { $_ . "\n" }
1101 $conf->config('invoice_email_pdf_note')
1106 warn "$me not using 'invoice_email_pdf_note' in multipart message"
1108 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1109 $data = $args{'print_text'};
1111 $data = [ $self->print_text(\%opt) ];
1116 $alternative->attach(
1117 'Type' => 'text/plain',
1118 'Encoding' => 'quoted-printable',
1119 #'Encoding' => '7bit',
1121 'Disposition' => 'inline',
1128 if ( $conf->exists('invoice_email_pdf')
1129 and scalar($conf->config('invoice_email_pdf_note')) ) {
1131 $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
1135 $args{'from'} =~ /\@([\w\.\-]+)/;
1136 my $from = $1 || 'example.com';
1137 my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1140 my $agentnum = $cust_main->agentnum;
1141 if ( defined($args{'template'}) && length($args{'template'})
1142 && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
1145 $logo = 'logo_'. $args{'template'}. '.png';
1149 my $image_data = $conf->config_binary( $logo, $agentnum);
1151 $image = build MIME::Entity
1152 'Type' => 'image/png',
1153 'Encoding' => 'base64',
1154 'Data' => $image_data,
1155 'Filename' => 'logo.png',
1156 'Content-ID' => "<$content_id>",
1159 if ($conf->exists('invoice-barcode')) {
1160 my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1161 $barcode = build MIME::Entity
1162 'Type' => 'image/png',
1163 'Encoding' => 'base64',
1164 'Data' => $self->invoice_barcode(0),
1165 'Filename' => 'barcode.png',
1166 'Content-ID' => "<$barcode_content_id>",
1168 $opt{'barcode_cid'} = $barcode_content_id;
1171 $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
1174 $alternative->attach(
1175 'Type' => 'text/html',
1176 'Encoding' => 'quoted-printable',
1177 'Data' => [ '<html>',
1180 ' '. encode_entities($return{'subject'}),
1183 ' <body bgcolor="#e8e8e8">',
1188 'Disposition' => 'inline',
1189 #'Filename' => 'invoice.pdf',
1193 my @otherparts = ();
1194 if ( $cust_main->email_csv_cdr ) {
1196 push @otherparts, build MIME::Entity
1197 'Type' => 'text/csv',
1198 'Encoding' => '7bit',
1199 'Data' => [ map { "$_\n" }
1200 $self->call_details('prepend_billed_number' => 1)
1202 'Disposition' => 'attachment',
1203 'Filename' => 'usage-'. $self->invnum. '.csv',
1208 if ( $conf->exists('invoice_email_pdf') ) {
1213 # multipart/alternative
1219 my $related = build MIME::Entity 'Type' => 'multipart/related',
1220 'Encoding' => '7bit';
1222 #false laziness w/Misc::send_email
1223 $related->head->replace('Content-type',
1224 $related->mime_type.
1225 '; boundary="'. $related->head->multipart_boundary. '"'.
1226 '; type=multipart/alternative'
1229 $related->add_part($alternative);
1231 $related->add_part($image) if $image;
1233 my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
1235 $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
1239 #no other attachment:
1241 # multipart/alternative
1246 $return{'content-type'} = 'multipart/related';
1247 if ($conf->exists('invoice-barcode') && $barcode) {
1248 $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
1250 $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
1252 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
1253 #$return{'disposition'} = 'inline';
1259 if ( $conf->exists('invoice_email_pdf') ) {
1260 warn "$me creating PDF attachment"
1263 #mime parts arguments a la MIME::Entity->build().
1264 $return{'mimeparts'} = [
1265 { $self->mimebuild_pdf(\%opt) }
1269 if ( $conf->exists('invoice_email_pdf')
1270 and scalar($conf->config('invoice_email_pdf_note')) ) {
1272 warn "$me using 'invoice_email_pdf_note'"
1274 $return{'body'} = [ map { $_ . "\n" }
1275 $conf->config('invoice_email_pdf_note')
1280 warn "$me not using 'invoice_email_pdf_note'"
1282 if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1283 $return{'body'} = $args{'print_text'};
1285 $return{'body'} = [ $self->print_text(\%opt) ];
1298 Returns a list suitable for passing to MIME::Entity->build(), representing
1299 this invoice as PDF attachment.
1306 'Type' => 'application/pdf',
1307 'Encoding' => 'base64',
1308 'Data' => [ $self->print_pdf(@_) ],
1309 'Disposition' => 'attachment',
1310 'Filename' => 'invoice-'. $self->invnum. '.pdf',
1314 =item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
1316 Sends this invoice to the destinations configured for this customer: sends
1317 email, prints and/or faxes. See L<FS::cust_main_invoice>.
1319 Options can be passed as a hashref (recommended) or as a list of up to
1320 four values for templatename, agentnum, invoice_from and amount.
1322 I<template>, if specified, is the name of a suffix for alternate invoices.
1324 I<agentnum>, if specified, means that this invoice will only be sent for customers
1325 of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a
1326 single agent) or an arrayref of agentnums.
1328 I<invoice_from>, if specified, overrides the default email invoice From: address.
1330 I<amount>, if specified, only sends the invoice if the total amount owed on this
1331 invoice and all older invoices is greater than the specified amount.
1333 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1335 I<lpr>, if specified, is passed to
1339 sub queueable_send {
1342 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1343 or die "invalid invoice number: " . $opt{invnum};
1345 my @args = ( $opt{template}, $opt{agentnum} );
1346 push @args, $opt{invoice_from}
1347 if exists($opt{invoice_from}) && $opt{invoice_from};
1349 my $error = $self->send( @args );
1350 die $error if $error;
1356 my $conf = $self->conf;
1358 my( $template, $invoice_from, $notice_name );
1360 my $balance_over = 0;
1365 $template = $opt->{'template'} || '';
1366 if ( $agentnums = $opt->{'agentnum'} ) {
1367 $agentnums = [ $agentnums ] unless ref($agentnums);
1369 $invoice_from = $opt->{'invoice_from'};
1370 $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
1371 $notice_name = $opt->{'notice_name'};
1372 $lpr = $opt->{'lpr'}
1374 $template = scalar(@_) ? shift : '';
1375 if ( scalar(@_) && $_[0] ) {
1376 $agentnums = ref($_[0]) ? shift : [ shift ];
1378 $invoice_from = shift if scalar(@_);
1379 $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
1382 my $cust_main = $self->cust_main;
1384 return 'N/A' unless ! $agentnums
1385 or grep { $_ == $cust_main->agentnum } @$agentnums;
1388 unless $cust_main->total_owed_date($self->_date) > $balance_over;
1390 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1391 $conf->config('invoice_from', $cust_main->agentnum );
1394 'template' => $template,
1395 'invoice_from' => $invoice_from,
1396 'notice_name' => ( $notice_name || 'Invoice' ),
1399 my @invoicing_list = $cust_main->invoicing_list;
1401 #$self->email_invoice(\%opt)
1403 if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
1404 && ! $self->invoice_noemail;
1407 #$self->print_invoice(\%opt)
1409 if grep { $_ eq 'POST' } @invoicing_list; #postal
1411 #this has never been used post-$ORIGINAL_ISP afaik
1412 $self->fax_invoice(\%opt)
1413 if grep { $_ eq 'FAX' } @invoicing_list; #fax
1419 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ]
1421 Emails this invoice.
1423 Options can be passed as a hashref (recommended) or as a list of up to
1424 two values for templatename and invoice_from.
1426 I<template>, if specified, is the name of a suffix for alternate invoices.
1428 I<invoice_from>, if specified, overrides the default email invoice From: address.
1430 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1434 sub queueable_email {
1437 my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1438 or die "invalid invoice number: " . $opt{invnum};
1440 my %args = ( 'template' => $opt{template} );
1441 $args{$_} = $opt{$_}
1442 foreach grep { exists($opt{$_}) && $opt{$_} }
1443 qw( invoice_from notice_name no_coupon );
1445 my $error = $self->email( \%args );
1446 die $error if $error;
1450 #sub email_invoice {
1453 return if $self->hide;
1454 my $conf = $self->conf;
1456 my( $template, $invoice_from, $notice_name, $no_coupon );
1459 $template = $opt->{'template'} || '';
1460 $invoice_from = $opt->{'invoice_from'};
1461 $notice_name = $opt->{'notice_name'} || 'Invoice';
1462 $no_coupon = $opt->{'no_coupon'} || 0;
1464 $template = scalar(@_) ? shift : '';
1465 $invoice_from = shift if scalar(@_);
1466 $notice_name = 'Invoice';
1470 $invoice_from ||= $self->_agent_invoice_from || #XXX should go away
1471 $conf->config('invoice_from', $self->cust_main->agentnum );
1473 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
1474 $self->cust_main->invoicing_list;
1476 if ( ! @invoicing_list ) { #no recipients
1477 if ( $conf->exists('cust_bill-no_recipients-error') ) {
1478 die 'No recipients for customer #'. $self->custnum;
1480 #default: better to notify this person than silence
1481 @invoicing_list = ($invoice_from);
1485 my $subject = $self->email_subject($template);
1487 my $error = send_email(
1488 $self->generate_email(
1489 'from' => $invoice_from,
1490 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
1491 'subject' => $subject,
1492 'template' => $template,
1493 'notice_name' => $notice_name,
1494 'no_coupon' => $no_coupon,
1497 die "can't email invoice: $error\n" if $error;
1498 #die "$error\n" if $error;
1504 my $conf = $self->conf;
1506 #my $template = scalar(@_) ? shift : '';
1509 my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
1512 my $cust_main = $self->cust_main;
1513 my $name = $cust_main->name;
1514 my $name_short = $cust_main->name_short;
1515 my $invoice_number = $self->invnum;
1516 my $invoice_date = $self->_date_pretty;
1518 eval qq("$subject");
1521 =item lpr_data HASHREF | [ TEMPLATE ]
1523 Returns the postscript or plaintext for this invoice as an arrayref.
1525 Options can be passed as a hashref (recommended) or as a single optional value
1528 I<template>, if specified, is the name of a suffix for alternate invoices.
1530 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1536 my $conf = $self->conf;
1537 my( $template, $notice_name );
1540 $template = $opt->{'template'} || '';
1541 $notice_name = $opt->{'notice_name'} || 'Invoice';
1543 $template = scalar(@_) ? shift : '';
1544 $notice_name = 'Invoice';
1548 'template' => $template,
1549 'notice_name' => $notice_name,
1552 my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
1553 [ $self->$method( \%opt ) ];
1556 =item print HASHREF | [ TEMPLATE ]
1558 Prints this invoice.
1560 Options can be passed as a hashref (recommended) or as a single optional
1563 I<template>, if specified, is the name of a suffix for alternate invoices.
1565 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1569 #sub print_invoice {
1572 return if $self->hide;
1573 my $conf = $self->conf;
1575 my( $template, $notice_name, $lpr );
1578 $template = $opt->{'template'} || '';
1579 $notice_name = $opt->{'notice_name'} || 'Invoice';
1580 $lpr = $opt->{'lpr'}
1582 $template = scalar(@_) ? shift : '';
1583 $notice_name = 'Invoice';
1588 'template' => $template,
1589 'notice_name' => $notice_name,
1592 if($conf->exists('invoice_print_pdf')) {
1593 # Add the invoice to the current batch.
1594 $self->batch_invoice(\%opt);
1598 $self->lpr_data(\%opt),
1599 'agentnum' => $self->cust_main->agentnum,
1605 =item fax_invoice HASHREF | [ TEMPLATE ]
1609 Options can be passed as a hashref (recommended) or as a single optional
1612 I<template>, if specified, is the name of a suffix for alternate invoices.
1614 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1620 return if $self->hide;
1621 my $conf = $self->conf;
1623 my( $template, $notice_name );
1626 $template = $opt->{'template'} || '';
1627 $notice_name = $opt->{'notice_name'} || 'Invoice';
1629 $template = scalar(@_) ? shift : '';
1630 $notice_name = 'Invoice';
1633 die 'FAX invoice destination not (yet?) supported with plain text invoices.'
1634 unless $conf->exists('invoice_latex');
1636 my $dialstring = $self->cust_main->getfield('fax');
1640 'template' => $template,
1641 'notice_name' => $notice_name,
1644 my $error = send_fax( 'docdata' => $self->lpr_data(\%opt),
1645 'dialstring' => $dialstring,
1647 die $error if $error;
1651 =item batch_invoice [ HASHREF ]
1653 Place this invoice into the open batch (see C<FS::bill_batch>). If there
1654 isn't an open batch, one will be created.
1659 my ($self, $opt) = @_;
1660 my $bill_batch = $self->get_open_bill_batch;
1661 my $cust_bill_batch = FS::cust_bill_batch->new({
1662 batchnum => $bill_batch->batchnum,
1663 invnum => $self->invnum,
1665 return $cust_bill_batch->insert($opt);
1668 =item get_open_batch
1670 Returns the currently open batch as an FS::bill_batch object, creating a new
1671 one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
1676 sub get_open_bill_batch {
1678 my $conf = $self->conf;
1679 my $hashref = { status => 'O' };
1680 $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
1681 ? $self->cust_main->agentnum
1683 my $batch = qsearchs('bill_batch', $hashref);
1684 return $batch if $batch;
1685 $batch = FS::bill_batch->new($hashref);
1686 my $error = $batch->insert;
1687 die $error if $error;
1691 =item ftp_invoice [ TEMPLATENAME ]
1693 Sends this invoice data via FTP.
1695 TEMPLATENAME is unused?
1701 my $conf = $self->conf;
1702 my $template = scalar(@_) ? shift : '';
1705 'protocol' => 'ftp',
1706 'server' => $conf->config('cust_bill-ftpserver'),
1707 'username' => $conf->config('cust_bill-ftpusername'),
1708 'password' => $conf->config('cust_bill-ftppassword'),
1709 'dir' => $conf->config('cust_bill-ftpdir'),
1710 'format' => $conf->config('cust_bill-ftpformat'),
1714 =item spool_invoice [ TEMPLATENAME ]
1716 Spools this invoice data (see L<FS::spool_csv>)
1718 TEMPLATENAME is unused?
1724 my $conf = $self->conf;
1725 my $template = scalar(@_) ? shift : '';
1728 'format' => $conf->config('cust_bill-spoolformat'),
1729 'agent_spools' => $conf->exists('cust_bill-spoolagent'),
1733 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
1735 Like B<send>, but only sends the invoice if it is the newest open invoice for
1740 sub send_if_newest {
1745 grep { $_->owed > 0 }
1746 qsearch('cust_bill', {
1747 'custnum' => $self->custnum,
1748 #'_date' => { op=>'>', value=>$self->_date },
1749 'invnum' => { op=>'>', value=>$self->invnum },
1756 =item send_csv OPTION => VALUE, ...
1758 Sends invoice as a CSV data-file to a remote host with the specified protocol.
1762 protocol - currently only "ftp"
1768 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
1769 and YYMMDDHHMMSS is a timestamp.
1771 See L</print_csv> for a description of the output format.
1776 my($self, %opt) = @_;
1780 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1781 mkdir $spooldir, 0700 unless -d $spooldir;
1783 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1784 my $file = "$spooldir/$tracctnum.csv";
1786 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1788 open(CSV, ">$file") or die "can't open $file: $!";
1796 if ( $opt{protocol} eq 'ftp' ) {
1797 eval "use Net::FTP;";
1799 $net = Net::FTP->new($opt{server}) or die @$;
1801 die "unknown protocol: $opt{protocol}";
1804 $net->login( $opt{username}, $opt{password} )
1805 or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
1807 $net->binary or die "can't set binary mode";
1809 $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
1811 $net->put($file) or die "can't put $file: $!";
1821 Spools CSV invoice data.
1827 =item format - any of FS::Misc::::Invoicing::spool_formats
1829 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
1830 customer has the corresponding invoice destinations set (see
1831 L<FS::cust_main_invoice>).
1833 =item agent_spools - if set to a true value, will spool to per-agent files
1834 rather than a single global file
1836 =item upload_targetnum - if set to a target (see L<FS::upload_target>), will
1837 append to that spool. L<FS::Cron::upload> will then send the spool file to
1840 =item balanceover - if set, only spools the invoice if the total amount owed on
1841 this invoice and all older invoices is greater than the specified amount.
1843 =item time - the "current time". Controls the printing of past due messages
1851 my($self, %opt) = @_;
1853 my $time = $opt{'time'} || time;
1854 my $cust_main = $self->cust_main;
1856 if ( $opt{'dest'} ) {
1857 my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
1858 $cust_main->invoicing_list;
1859 return 'N/A' unless $invoicing_list{$opt{'dest'}}
1860 || ! keys %invoicing_list;
1863 if ( $opt{'balanceover'} ) {
1865 if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
1868 my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1869 mkdir $spooldir, 0700 unless -d $spooldir;
1871 my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', $time);
1874 if ( $opt{'agent_spools'} ) {
1875 $file = 'agentnum'.$cust_main->agentnum;
1880 if ( $opt{'upload_targetnum'} ) {
1881 $spooldir .= '/target'.$opt{'upload_targetnum'};
1882 mkdir $spooldir, 0700 unless -d $spooldir;
1883 } # otherwise it just goes into export.xxx/cust_bill
1885 if ( lc($opt{'format'}) eq 'billco' ) {
1889 $file = "$spooldir/$file.csv";
1891 my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum);
1893 open(CSV, ">>$file") or die "can't open $file: $!";
1894 flock(CSV, LOCK_EX);
1899 if ( lc($opt{'format'}) eq 'billco' ) {
1901 flock(CSV, LOCK_UN);
1904 $file =~ s/-header.csv$/-detail.csv/;
1906 open(CSV,">>$file") or die "can't open $file: $!";
1907 flock(CSV, LOCK_EX);
1911 print CSV $detail if defined($detail);
1913 flock(CSV, LOCK_UN);
1920 =item print_csv OPTION => VALUE, ...
1922 Returns CSV data for this invoice.
1926 format - 'default', 'billco', 'oneline', 'bridgestone'
1928 Returns a list consisting of two scalars. The first is a single line of CSV
1929 header information for this invoice. The second is one or more lines of CSV
1930 detail information for this invoice.
1932 If I<format> is not specified or "default", the fields of the CSV file are as
1935 record_type, invnum, custnum, _date, charged, first, last, company, address1,
1936 address2, city, state, zip, country, pkg, setup, recur, sdate, edate
1940 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
1942 B<record_type> is C<cust_bill> for the initial header line only. The
1943 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
1944 fields are filled in.
1946 B<record_type> is C<cust_bill_pkg> for detail lines. Only the first two fields
1947 (B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
1950 =item invnum - invoice number
1952 =item custnum - customer number
1954 =item _date - invoice date
1956 =item charged - total invoice amount
1958 =item first - customer first name
1960 =item last - customer first name
1962 =item company - company name
1964 =item address1 - address line 1
1966 =item address2 - address line 1
1976 =item pkg - line item description
1978 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
1980 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
1982 =item sdate - start date for recurring fee
1984 =item edate - end date for recurring fee
1988 If I<format> is "billco", the fields of the header CSV file are as follows:
1990 +-------------------------------------------------------------------+
1991 | FORMAT HEADER FILE |
1992 |-------------------------------------------------------------------|
1993 | Field | Description | Name | Type | Width |
1994 | 1 | N/A-Leave Empty | RC | CHAR | 2 |
1995 | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 |
1996 | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 |
1997 | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 |
1998 | 5 | Transaction Zip Code | TRZIP | CHAR | 5 |
1999 | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 |
2000 | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 |
2001 | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 |
2002 | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 |
2003 | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 |
2004 | 11 | Transaction City Bill To | TRCITY | CHAR | 20 |
2005 | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 |
2006 | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 |
2007 | 14 | Bill Due Date | DUEDATE | CHAR | 10 |
2008 | 15 | Previous Balance | BALFWD | NUM* | 9 |
2009 | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 |
2010 | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 |
2011 | 18 | Total Amt Due | TOTALDUE | NUM* | 9 |
2012 | 19 | Total Amt Due | AMTDUE | NUM* | 9 |
2013 | 20 | 30 Day Aging | AMT30 | NUM* | 9 |
2014 | 21 | 60 Day Aging | AMT60 | NUM* | 9 |
2015 | 22 | 90 Day Aging | AMT90 | NUM* | 9 |
2016 | 23 | Y/N | AGESWITCH | CHAR | 1 |
2017 | 24 | Remittance automation | SCANLINE | CHAR | 100 |
2018 | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 |
2019 | 26 | Customer Reference Number | CUSTREF | CHAR | 15 |
2020 | 27 | Federal Tax*** | FEDTAX | NUM* | 9 |
2021 | 28 | State Tax*** | STATETAX | NUM* | 9 |
2022 | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 |
2023 +-------+-------------------------------+------------+------+-------+
2025 If I<format> is "billco", the fields of the detail CSV file are as follows:
2027 FORMAT FOR DETAIL FILE
2029 Field | Description | Name | Type | Width
2030 1 | N/A-Leave Empty | RC | CHAR | 2
2031 2 | N/A-Leave Empty | CUSTID | CHAR | 15
2032 3 | Account Number | TRACCTNUM | CHAR | 15
2033 4 | Invoice Number | TRINVOICE | CHAR | 15
2034 5 | Line Sequence (sort order) | LINESEQ | NUM | 6
2035 6 | Transaction Detail | DETAILS | CHAR | 100
2036 7 | Amount | AMT | NUM* | 9
2037 8 | Line Format Control** | LNCTRL | CHAR | 2
2038 9 | Grouping Code | GROUP | CHAR | 2
2039 10 | User Defined | ACCT CODE | CHAR | 15
2041 If format is 'oneline', there is no detail file. Each invoice has a
2042 header line only, with the fields:
2044 Agent number, agent name, customer number, first name, last name, address
2045 line 1, address line 2, city, state, zip, invoice date, invoice number,
2046 amount charged, amount due, previous balance, due date.
2048 and then, for each line item, three columns containing the package number,
2049 description, and amount.
2051 If format is 'bridgestone', there is no detail file. Each invoice has a
2052 header line with the following fields in a fixed-width format:
2054 Customer number (in display format), date, name (first last), company,
2055 address 1, address 2, city, state, zip.
2057 This is a mailing list format, and has no per-invoice fields. To avoid
2058 sending redundant notices, the spooling event should have a "once" or
2059 "once_percust_every" condition.
2064 my($self, %opt) = @_;
2066 eval "use Text::CSV_XS";
2069 my $cust_main = $self->cust_main;
2071 my $csv = Text::CSV_XS->new({'always_quote'=>1});
2072 my $format = lc($opt{'format'});
2074 my $time = $opt{'time'} || time;
2076 if ( $format eq 'billco' ) {
2079 $taxtotal += $_->{'amount'} foreach $self->_items_tax;
2081 my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
2083 my( $previous_balance, @unused ) = $self->previous; #previous balance
2085 my $pmt_cr_applied = 0;
2086 $pmt_cr_applied += $_->{'amount'}
2087 foreach ( $self->_items_payments, $self->_items_credits ) ;
2089 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2092 '', # 1 | N/A-Leave Empty CHAR 2
2093 '', # 2 | N/A-Leave Empty CHAR 15
2094 $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
2095 $self->invnum, # 4 | Transaction Invoice No CHAR 15
2096 $cust_main->zip, # 5 | Transaction Zip Code CHAR 5
2097 $cust_main->company, # 6 | Transaction Company Bill To CHAR 30
2098 #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30
2099 $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30
2100 $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30
2101 $cust_main->address1, # 9 | Bill To Street Address CHAR 30
2102 '', # 10 | Ancillary Billing Information CHAR 30
2103 $cust_main->city, # 11 | Transaction City Bill To CHAR 20
2104 $cust_main->state, # 12 | Transaction State Bill To CHAR 2
2107 time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10
2110 $duedate, # 14 | Bill Due Date CHAR 10
2112 $previous_balance, # 15 | Previous Balance NUM* 9
2113 $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9
2114 sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9
2115 $totaldue, # 18 | Total Amt Due NUM* 9
2116 $totaldue, # 19 | Total Amt Due NUM* 9
2117 '', # 20 | 30 Day Aging NUM* 9
2118 '', # 21 | 60 Day Aging NUM* 9
2119 '', # 22 | 90 Day Aging NUM* 9
2120 'N', # 23 | Y/N CHAR 1
2121 '', # 24 | Remittance automation CHAR 100
2122 $taxtotal, # 25 | Total Taxes & Fees NUM* 9
2123 $self->custnum, # 26 | Customer Reference Number CHAR 15
2124 '0', # 27 | Federal Tax*** NUM* 9
2125 sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9
2126 '0', # 29 | Other Taxes & Fees*** NUM* 9
2129 } elsif ( $format eq 'oneline' ) { #name
2131 my ($previous_balance) = $self->previous;
2132 $previous_balance = sprintf('%.2f', $previous_balance);
2133 my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
2139 $self->_items_pkg, #_items_nontax? no sections or anything
2144 $cust_main->agentnum,
2145 $cust_main->agent->agent,
2149 $cust_main->company,
2150 $cust_main->address1,
2151 $cust_main->address2,
2157 time2str("%x", $self->_date),
2162 $self->due_date2str("%x"),
2167 } elsif ( $format eq 'bridgestone' ) {
2169 # bypass the CSV stuff and just return this
2170 my $longdate = time2str('%B %d, %Y', $time); #current time, right?
2171 my $zip = $cust_main->zip;
2173 my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
2177 "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
2179 $cust_main->display_custnum,
2181 uc(substr($cust_main->contact_firstlast,0,30)),
2182 uc(substr($cust_main->company ,0,30)),
2183 uc(substr($cust_main->address1 ,0,30)),
2184 uc(substr($cust_main->address2 ,0,30)),
2185 uc(substr($cust_main->city ,0,20)),
2186 uc($cust_main->state),
2192 } elsif ( $format eq 'ics' ) {
2194 my $bill = $cust_main->bill_location;
2195 my $zip = $bill->zip;
2199 if ( $zip =~ /^(\d{5})(\d{4})$/ ) {
2204 # minor false laziness with print_generic
2205 my ($previous_balance) = $self->previous;
2206 my $balance_due = $self->owed + $previous_balance;
2207 my $payment_total = sum(0, map { $_->{'amount'} } $self->_items_payments);
2208 my $credit_total = sum(0, map { $_->{'amount'} } $self->_items_credits);
2211 if ( $self->due_date and $time >= $self->due_date ) {
2212 $past_due = sprintf('Past due:$%0.2f Due Immediately', $balance_due);
2216 my $header = sprintf(
2217 '%-10s%-30s%-48s%-2s%-50s%-30s%-30s%-25s%-2s%-5s%-4s%-8s%-8s%-10s%-10s%-10s%-10s%-10s%-10s%-480s%-35s',
2218 $cust_main->display_custnum, #BID
2219 uc($cust_main->first), #FNAME
2220 uc($cust_main->last), #LNAME
2221 '00', #BATCH, should this ever be anything else?
2222 uc($cust_main->company), #COMP
2223 uc($bill->address1), #STREET1
2224 uc($bill->address2), #STREET2
2225 uc($bill->city), #CITY
2226 uc($bill->state), #STATE
2229 time2str('%Y%m%d', $self->_date), #BILL_DATE
2230 $self->due_date2str('%Y%m%d'), #DUE_DATE,
2231 ( map {sprintf('%0.2f', $_)}
2232 $balance_due, #AMNT_DUE
2233 $previous_balance, #PREV_BAL
2234 $payment_total, #PYMT_RCVD
2235 $credit_total, #CREDITS
2236 $previous_balance, #BEG_BAL--is this correct?
2237 $self->charged, #NEW_CHRG
2240 $past_due, #PAST_MSG
2244 my %svc_class = ('' => ''); # maybe cache this more persistently?
2246 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2248 my $show_pkgnum = $cust_bill_pkg->pkgnum || '';
2249 my $cust_pkg = $cust_bill_pkg->cust_pkg if $show_pkgnum;
2253 my @dates = ( $self->_date, undef );
2254 if ( my $prev = $cust_bill_pkg->previous_cust_bill_pkg ) {
2255 $dates[1] = $prev->sdate; #questionable
2258 # generate an 01 detail for each service
2259 my @svcs = $cust_pkg->h_cust_svc(@dates, 'I');
2260 foreach my $cust_svc ( @svcs ) {
2261 $show_pkgnum = ''; # hide it if we're showing svcnums
2263 my $svcpart = $cust_svc->svcpart;
2264 if (!exists($svc_class{$svcpart})) {
2265 my $classnum = $cust_svc->part_svc->classnum;
2266 my $part_svc_class = FS::part_svc_class->by_key($classnum)
2268 $svc_class{$svcpart} = $part_svc_class ?
2269 $part_svc_class->classname :
2273 my @h_label = $cust_svc->label(@dates, 'I');
2274 push @details, sprintf('01%-9s%-20s%-47s',
2276 $svc_class{$svcpart},
2279 } #foreach $cust_svc
2282 my $desc = $cust_bill_pkg->desc; # itemdesc or part_pkg.pkg
2283 if ($cust_bill_pkg->recur > 0) {
2284 $desc .= ' '.time2str('%d-%b-%Y', $cust_bill_pkg->sdate).' to '.
2285 time2str('%d-%b-%Y', $cust_bill_pkg->edate - 86400);
2287 push @details, sprintf('02%-6s%-60s%-10s',
2290 sprintf('%0.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
2292 } #foreach $cust_bill_pkg
2294 # Tag this row so that we know whether this is one page (1), two pages
2295 # (2), # or "big" (B). The tag will be stripped off before uploading.
2296 if ( scalar(@details) < 12 ) {
2298 } elsif ( scalar(@details) < 58 ) {
2304 return join('', $header, @details, "\n");
2312 time2str("%x", $self->_date),
2313 sprintf("%.2f", $self->charged),
2314 ( map { $cust_main->getfield($_) }
2315 qw( first last company address1 address2 city state zip country ) ),
2317 ) or die "can't create csv";
2320 my $header = $csv->string. "\n";
2323 if ( lc($opt{'format'}) eq 'billco' ) {
2326 foreach my $item ( $self->_items_pkg ) {
2329 '', # 1 | N/A-Leave Empty CHAR 2
2330 '', # 2 | N/A-Leave Empty CHAR 15
2331 $opt{'tracctnum'}, # 3 | Account Number CHAR 15
2332 $self->invnum, # 4 | Invoice Number CHAR 15
2333 $lineseq++, # 5 | Line Sequence (sort order) NUM 6
2334 $item->{'description'}, # 6 | Transaction Detail CHAR 100
2335 $item->{'amount'}, # 7 | Amount NUM* 9
2336 '', # 8 | Line Format Control** CHAR 2
2337 '', # 9 | Grouping Code CHAR 2
2338 '', # 10 | User Defined CHAR 15
2341 $detail .= $csv->string. "\n";
2345 } elsif ( lc($opt{'format'}) eq 'oneline' ) {
2351 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2353 my($pkg, $setup, $recur, $sdate, $edate);
2354 if ( $cust_bill_pkg->pkgnum ) {
2356 ($pkg, $setup, $recur, $sdate, $edate) = (
2357 $cust_bill_pkg->part_pkg->pkg,
2358 ( $cust_bill_pkg->setup != 0
2359 ? sprintf("%.2f", $cust_bill_pkg->setup )
2361 ( $cust_bill_pkg->recur != 0
2362 ? sprintf("%.2f", $cust_bill_pkg->recur )
2364 ( $cust_bill_pkg->sdate
2365 ? time2str("%x", $cust_bill_pkg->sdate)
2367 ($cust_bill_pkg->edate
2368 ?time2str("%x", $cust_bill_pkg->edate)
2372 } else { #pkgnum tax
2373 next unless $cust_bill_pkg->setup != 0;
2374 $pkg = $cust_bill_pkg->desc;
2375 $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
2376 ( $sdate, $edate ) = ( '', '' );
2382 ( map { '' } (1..11) ),
2383 ($pkg, $setup, $recur, $sdate, $edate)
2384 ) or die "can't create csv";
2386 $detail .= $csv->string. "\n";
2392 ( $header, $detail );
2398 Pays this invoice with a compliemntary payment. If there is an error,
2399 returns the error, otherwise returns false.
2405 my $cust_pay = new FS::cust_pay ( {
2406 'invnum' => $self->invnum,
2407 'paid' => $self->owed,
2410 'payinfo' => $self->cust_main->payinfo,
2418 Attempts to pay this invoice with a credit card payment via a
2419 Business::OnlinePayment realtime gateway. See
2420 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2421 for supported processors.
2427 $self->realtime_bop( 'CC', @_ );
2432 Attempts to pay this invoice with an electronic check (ACH) payment via a
2433 Business::OnlinePayment realtime gateway. See
2434 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2435 for supported processors.
2441 $self->realtime_bop( 'ECHECK', @_ );
2446 Attempts to pay this invoice with phone bill (LEC) payment via a
2447 Business::OnlinePayment realtime gateway. See
2448 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2449 for supported processors.
2455 $self->realtime_bop( 'LEC', @_ );
2459 my( $self, $method ) = (shift,shift);
2460 my $conf = $self->conf;
2463 my $cust_main = $self->cust_main;
2464 my $balance = $cust_main->balance;
2465 my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
2466 $amount = sprintf("%.2f", $amount);
2467 return "not run (balance $balance)" unless $amount > 0;
2469 my $description = 'Internet Services';
2470 if ( $conf->exists('business-onlinepayment-description') ) {
2471 my $dtempl = $conf->config('business-onlinepayment-description');
2473 my $agent_obj = $cust_main->agent
2474 or die "can't retreive agent for $cust_main (agentnum ".
2475 $cust_main->agentnum. ")";
2476 my $agent = $agent_obj->agent;
2477 my $pkgs = join(', ',
2478 map { $_->part_pkg->pkg }
2479 grep { $_->pkgnum } $self->cust_bill_pkg
2481 $description = eval qq("$dtempl");
2484 $cust_main->realtime_bop($method, $amount,
2485 'description' => $description,
2486 'invnum' => $self->invnum,
2487 #this didn't do what we want, it just calls apply_payments_and_credits
2489 'apply_to_invoice' => 1,
2492 #this changes application behavior: auto payments
2493 #triggered against a specific invoice are now applied
2494 #to that invoice instead of oldest open.
2500 =item batch_card OPTION => VALUE...
2502 Adds a payment for this invoice to the pending credit card batch (see
2503 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2504 runs the payment using a realtime gateway.
2509 my ($self, %options) = @_;
2510 my $cust_main = $self->cust_main;
2512 $options{invnum} = $self->invnum;
2514 $cust_main->batch_card(%options);
2517 sub _agent_template {
2519 $self->cust_main->agent_template;
2522 sub _agent_invoice_from {
2524 $self->cust_main->agent_invoice_from;
2527 =item invoice_barcode DIR_OR_FALSE
2529 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
2530 it is taken as the temp directory where the PNG file will be generated and the
2531 PNG file name is returned. Otherwise, the PNG image itself is returned.
2535 sub invoice_barcode {
2536 my ($self, $dir) = (shift,shift);
2538 my $gdbar = new GD::Barcode('Code39',$self->invnum);
2539 die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
2540 my $gd = $gdbar->plot(Height => 30);
2543 my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
2547 ) or die "can't open temp file: $!\n";
2548 print $bh $gd->png or die "cannot write barcode to file: $!\n";
2549 my $png_file = $bh->filename;
2556 =item invnum_date_pretty
2558 Returns a string with the invoice number and date, for example:
2559 "Invoice #54 (3/20/2008)"
2563 sub invnum_date_pretty {
2565 $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
2568 #sub _items_extra_usage_sections {
2570 # my $escape = shift;
2572 # my %sections = ();
2574 # my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
2575 # foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2577 # next unless $cust_bill_pkg->pkgnum > 0;
2579 # foreach my $section ( keys %usage_class ) {
2581 # my $usage = $cust_bill_pkg->usage($section);
2583 # next unless $usage && $usage > 0;
2585 # $sections{$section} ||= 0;
2586 # $sections{$section} += $usage;
2592 # map { { 'description' => &{$escape}($_),
2593 # 'subtotal' => $sections{$_},
2594 # 'summarized' => '',
2595 # 'tax_section' => '',
2598 # sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
2602 sub _items_extra_usage_sections {
2604 my $conf = $self->conf;
2612 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2614 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2615 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2616 next unless $cust_bill_pkg->pkgnum > 0;
2618 foreach my $classnum ( keys %usage_class ) {
2619 my $section = $usage_class{$classnum}->classname;
2620 $classnums{$section} = $classnum;
2622 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
2623 my $amount = $detail->amount;
2624 next unless $amount && $amount > 0;
2626 $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
2627 $sections{$section}{amount} += $amount; #subtotal
2628 $sections{$section}{calls}++;
2629 $sections{$section}{duration} += $detail->duration;
2631 my $desc = $detail->regionname;
2632 my $description = $desc;
2633 $description = substr($desc, 0, $maxlength). '...'
2634 if $format eq 'latex' && length($desc) > $maxlength;
2636 $lines{$section}{$desc} ||= {
2637 description => &{$escape}($description),
2638 #pkgpart => $part_pkg->pkgpart,
2639 pkgnum => $cust_bill_pkg->pkgnum,
2644 #unit_amount => $cust_bill_pkg->unitrecur,
2645 quantity => $cust_bill_pkg->quantity,
2646 product_code => 'N/A',
2647 ext_description => [],
2650 $lines{$section}{$desc}{amount} += $amount;
2651 $lines{$section}{$desc}{calls}++;
2652 $lines{$section}{$desc}{duration} += $detail->duration;
2658 my %sectionmap = ();
2659 foreach (keys %sections) {
2660 my $usage_class = $usage_class{$classnums{$_}};
2661 $sectionmap{$_} = { 'description' => &{$escape}($_),
2662 'amount' => $sections{$_}{amount}, #subtotal
2663 'calls' => $sections{$_}{calls},
2664 'duration' => $sections{$_}{duration},
2666 'tax_section' => '',
2667 'sort_weight' => $usage_class->weight,
2668 ( $usage_class->format
2669 ? ( map { $_ => $usage_class->$_($format) }
2670 qw( description_generator header_generator total_generator total_line_generator )
2677 my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
2681 foreach my $section ( keys %lines ) {
2682 foreach my $line ( keys %{$lines{$section}} ) {
2683 my $l = $lines{$section}{$line};
2684 $l->{section} = $sectionmap{$section};
2685 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2686 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2691 return(\@sections, \@lines);
2697 my $end = $self->_date;
2699 # start at date of previous invoice + 1 second or 0 if no previous invoice
2700 my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
2701 $start = 0 if !$start;
2704 my $cust_main = $self->cust_main;
2705 my @pkgs = $cust_main->all_pkgs;
2706 my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
2709 foreach my $pkg ( @pkgs ) {
2710 my @h_cust_svc = $pkg->h_cust_svc($end);
2711 foreach my $h_cust_svc ( @h_cust_svc ) {
2712 next if grep {$_ eq $h_cust_svc->svcnum} @seen;
2713 next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
2715 my $inserted = $h_cust_svc->date_inserted;
2716 my $deleted = $h_cust_svc->date_deleted;
2717 my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
2719 $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted;
2721 # DID either activated or ported in; cannot be both for same DID simultaneously
2722 if ($inserted >= $start && $inserted <= $end && $phone_inserted
2723 && (!$phone_inserted->lnp_status
2724 || $phone_inserted->lnp_status eq ''
2725 || $phone_inserted->lnp_status eq 'native')) {
2728 else { # this one not so clean, should probably move to (h_)svc_phone
2729 my $phone_portedin = qsearchs( 'h_svc_phone',
2730 { 'svcnum' => $h_cust_svc->svcnum,
2731 'lnp_status' => 'portedin' },
2732 FS::h_svc_phone->sql_h_searchs($end),
2734 $num_portedin++ if $phone_portedin;
2737 # DID either deactivated or ported out; cannot be both for same DID simultaneously
2738 if($deleted >= $start && $deleted <= $end && $phone_deleted
2739 && (!$phone_deleted->lnp_status
2740 || $phone_deleted->lnp_status ne 'portingout')) {
2743 elsif($deleted >= $start && $deleted <= $end && $phone_deleted
2744 && $phone_deleted->lnp_status
2745 && $phone_deleted->lnp_status eq 'portingout') {
2749 # increment usage minutes
2750 if ( $phone_inserted ) {
2751 my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
2752 $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
2755 warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
2758 # don't look at this service again
2759 push @seen, $h_cust_svc->svcnum;
2763 $minutes = sprintf("%d", $minutes);
2764 ("Activated: $num_activated Ported-In: $num_portedin Deactivated: "
2765 . "$num_deactivated Ported-Out: $num_portedout ",
2766 "Total Minutes: $minutes");
2769 sub _items_accountcode_cdr {
2774 my $section = { 'amount' => 0,
2777 'sort_weight' => '',
2779 'description' => 'Usage by Account Code',
2785 my %accountcodes = ();
2787 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2788 next unless $cust_bill_pkg->pkgnum > 0;
2790 my @header = $cust_bill_pkg->details_header;
2791 next unless scalar(@header);
2792 $section->{'header'} = join(',',@header);
2794 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2796 $section->{'header'} = $detail->formatted('format' => $format)
2797 if($detail->detail eq $section->{'header'});
2799 my $accountcode = $detail->accountcode;
2800 next unless $accountcode;
2802 my $amount = $detail->amount;
2803 next unless $amount && $amount > 0;
2805 $accountcodes{$accountcode} ||= {
2806 description => $accountcode,
2813 product_code => 'N/A',
2814 section => $section,
2815 ext_description => [ $section->{'header'} ],
2819 $section->{'amount'} += $amount;
2820 $accountcodes{$accountcode}{'amount'} += $amount;
2821 $accountcodes{$accountcode}{calls}++;
2822 $accountcodes{$accountcode}{duration} += $detail->duration;
2823 push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
2827 foreach my $l ( values %accountcodes ) {
2828 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2829 my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
2830 foreach my $sorted_detail ( @sorted_detail ) {
2831 push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
2833 delete $l->{detail_temp};
2837 my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
2839 return ($section,\@sorted_lines);
2842 sub _items_svc_phone_sections {
2844 my $conf = $self->conf;
2852 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2854 my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2855 $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
2857 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2858 next unless $cust_bill_pkg->pkgnum > 0;
2860 my @header = $cust_bill_pkg->details_header;
2861 next unless scalar(@header);
2863 foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2865 my $phonenum = $detail->phonenum;
2866 next unless $phonenum;
2868 my $amount = $detail->amount;
2869 next unless $amount && $amount > 0;
2871 $sections{$phonenum} ||= { 'amount' => 0,
2874 'sort_weight' => -1,
2875 'phonenum' => $phonenum,
2877 $sections{$phonenum}{amount} += $amount; #subtotal
2878 $sections{$phonenum}{calls}++;
2879 $sections{$phonenum}{duration} += $detail->duration;
2881 my $desc = $detail->regionname;
2882 my $description = $desc;
2883 $description = substr($desc, 0, $maxlength). '...'
2884 if $format eq 'latex' && length($desc) > $maxlength;
2886 $lines{$phonenum}{$desc} ||= {
2887 description => &{$escape}($description),
2888 #pkgpart => $part_pkg->pkgpart,
2896 product_code => 'N/A',
2897 ext_description => [],
2900 $lines{$phonenum}{$desc}{amount} += $amount;
2901 $lines{$phonenum}{$desc}{calls}++;
2902 $lines{$phonenum}{$desc}{duration} += $detail->duration;
2904 my $line = $usage_class{$detail->classnum}->classname;
2905 $sections{"$phonenum $line"} ||=
2909 'sort_weight' => $usage_class{$detail->classnum}->weight,
2910 'phonenum' => $phonenum,
2911 'header' => [ @header ],
2913 $sections{"$phonenum $line"}{amount} += $amount; #subtotal
2914 $sections{"$phonenum $line"}{calls}++;
2915 $sections{"$phonenum $line"}{duration} += $detail->duration;
2917 $lines{"$phonenum $line"}{$desc} ||= {
2918 description => &{$escape}($description),
2919 #pkgpart => $part_pkg->pkgpart,
2927 product_code => 'N/A',
2928 ext_description => [],
2931 $lines{"$phonenum $line"}{$desc}{amount} += $amount;
2932 $lines{"$phonenum $line"}{$desc}{calls}++;
2933 $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
2934 push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
2935 $detail->formatted('format' => $format);
2940 my %sectionmap = ();
2941 my $simple = new FS::usage_class { format => 'simple' }; #bleh
2942 foreach ( keys %sections ) {
2943 my @header = @{ $sections{$_}{header} || [] };
2945 new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
2946 my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
2947 my $usage_class = $summary ? $simple : $usage_simple;
2948 my $ending = $summary ? ' usage charges' : '';
2951 $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
2953 $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
2954 'amount' => $sections{$_}{amount}, #subtotal
2955 'calls' => $sections{$_}{calls},
2956 'duration' => $sections{$_}{duration},
2958 'tax_section' => '',
2959 'phonenum' => $sections{$_}{phonenum},
2960 'sort_weight' => $sections{$_}{sort_weight},
2961 'post_total' => $summary, #inspire pagebreak
2963 ( map { $_ => $usage_class->$_($format, %gen_opt) }
2964 qw( description_generator
2967 total_line_generator
2974 my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
2975 $a->{sort_weight} <=> $b->{sort_weight}
2980 foreach my $section ( keys %lines ) {
2981 foreach my $line ( keys %{$lines{$section}} ) {
2982 my $l = $lines{$section}{$line};
2983 $l->{section} = $sectionmap{$section};
2984 $l->{amount} = sprintf( "%.2f", $l->{amount} );
2985 #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2990 if($conf->exists('phone_usage_class_summary')) {
2991 # this only works with Latex
2995 # after this, we'll have only two sections per DID:
2996 # Calls Summary and Calls Detail
2997 foreach my $section ( @sections ) {
2998 if($section->{'post_total'}) {
2999 $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
3000 $section->{'total_line_generator'} = sub { '' };
3001 $section->{'total_generator'} = sub { '' };
3002 $section->{'header_generator'} = sub { '' };
3003 $section->{'description_generator'} = '';
3004 push @newsections, $section;
3005 my %calls_detail = %$section;
3006 $calls_detail{'post_total'} = '';
3007 $calls_detail{'sort_weight'} = '';
3008 $calls_detail{'description_generator'} = sub { '' };
3009 $calls_detail{'header_generator'} = sub {
3010 return ' & Date/Time & Called Number & Duration & Price'
3011 if $format eq 'latex';
3014 $calls_detail{'description'} = 'Calls Detail: '
3015 . $section->{'phonenum'};
3016 push @newsections, \%calls_detail;
3020 # after this, each usage class is collapsed/summarized into a single
3021 # line under the Calls Summary section
3022 foreach my $newsection ( @newsections ) {
3023 if($newsection->{'post_total'}) { # this means Calls Summary
3024 foreach my $section ( @sections ) {
3025 next unless ($section->{'phonenum'} eq $newsection->{'phonenum'}
3026 && !$section->{'post_total'});
3027 my $newdesc = $section->{'description'};
3028 my $tn = $section->{'phonenum'};
3029 $newdesc =~ s/$tn//g;
3030 my $line = { ext_description => [],
3034 calls => $section->{'calls'},
3035 section => $newsection,
3036 duration => $section->{'duration'},
3037 description => $newdesc,
3038 amount => sprintf("%.2f",$section->{'amount'}),
3039 product_code => 'N/A',
3041 push @newlines, $line;
3046 # after this, Calls Details is populated with all CDRs
3047 foreach my $newsection ( @newsections ) {
3048 if(!$newsection->{'post_total'}) { # this means Calls Details
3049 foreach my $line ( @lines ) {
3050 next unless (scalar(@{$line->{'ext_description'}}) &&
3051 $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
3053 my @extdesc = @{$line->{'ext_description'}};
3055 foreach my $extdesc ( @extdesc ) {
3056 $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
3057 push @newextdesc, $extdesc;
3059 $line->{'ext_description'} = \@newextdesc;
3060 $line->{'section'} = $newsection;
3061 push @newlines, $line;
3066 return(\@newsections, \@newlines);
3069 return(\@sections, \@lines);
3073 sub _items_previous {
3075 my $conf = $self->conf;
3076 my $cust_main = $self->cust_main;
3077 my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
3079 foreach ( @pr_cust_bill ) {
3080 my $date = $conf->exists('invoice_show_prior_due_date')
3081 ? 'due '. $_->due_date2str($date_format)
3082 : time2str($date_format, $_->_date);
3084 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
3085 #'pkgpart' => 'N/A',
3087 'amount' => sprintf("%.2f", $_->owed),
3093 # 'description' => 'Previous Balance',
3094 # #'pkgpart' => 'N/A',
3095 # 'pkgnum' => 'N/A',
3096 # 'amount' => sprintf("%10.2f", $pr_total ),
3097 # 'ext_description' => [ map {
3098 # "Invoice ". $_->invnum.
3099 # " (". time2str("%x",$_->_date). ") ".
3100 # sprintf("%10.2f", $_->owed)
3101 # } @pr_cust_bill ],
3106 sub _items_credits {
3107 my( $self, %opt ) = @_;
3108 my $trim_len = $opt{'trim_len'} || 60;
3112 foreach ( $self->cust_credited ) {
3114 #something more elaborate if $_->amount ne $_->cust_credit->credited ?
3116 my $reason = substr($_->cust_credit->reason, 0, $trim_len);
3117 $reason .= '...' if length($reason) < length($_->cust_credit->reason);
3118 $reason = " ($reason) " if $reason;
3121 #'description' => 'Credit ref\#'. $_->crednum.
3122 # " (". time2str("%x",$_->cust_credit->_date) .")".
3124 'description' => $self->mt('Credit applied').' '.
3125 time2str($date_format,$_->cust_credit->_date). $reason,
3126 'amount' => sprintf("%.2f",$_->amount),
3134 sub _items_payments {
3138 #get & print payments
3139 foreach ( $self->cust_bill_pay ) {
3141 #something more elaborate if $_->amount ne ->cust_pay->paid ?
3143 my $desc = $self->mt('Payment received').' '.
3144 time2str($date_format,$_->cust_pay->_date );
3145 $desc .= $self->mt(' via ' . $_->cust_pay->payby_payinfo_pretty)
3146 if ( $self->conf->exists('invoice_payment_details') );
3149 'description' => $desc,
3150 'amount' => sprintf("%.2f", $_->amount )
3159 =item call_details [ OPTION => VALUE ... ]
3161 Returns an array of CSV strings representing the call details for this invoice
3162 The only option available is the boolean prepend_billed_number
3167 my ($self, %opt) = @_;
3169 my $format_function = sub { shift };
3171 if ($opt{prepend_billed_number}) {
3172 $format_function = sub {
3176 $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
3181 my @details = map { $_->details( 'format_function' => $format_function,
3182 'escape_function' => sub{ return() },
3186 $self->cust_bill_pkg;
3187 my $header = $details[0];
3188 ( $header, grep { $_ ne $header } @details );
3198 =item process_reprint
3202 sub process_reprint {
3203 process_re_X('print', @_);
3206 =item process_reemail
3210 sub process_reemail {
3211 process_re_X('email', @_);
3219 process_re_X('fax', @_);
3227 process_re_X('ftp', @_);
3234 sub process_respool {
3235 process_re_X('spool', @_);
3238 use Storable qw(thaw);
3242 my( $method, $job ) = ( shift, shift );
3243 warn "$me process_re_X $method for job $job\n" if $DEBUG;
3245 my $param = thaw(decode_base64(shift));
3246 warn Dumper($param) if $DEBUG;
3257 # spool_invoice ftp_invoice fax_invoice print_invoice
3258 my($method, $job, %param ) = @_;
3260 warn "re_X $method for job $job with param:\n".
3261 join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
3264 #some false laziness w/search/cust_bill.html
3266 my $orderby = 'ORDER BY cust_bill._date';
3268 my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
3270 my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
3272 my @cust_bill = qsearch( {
3273 #'select' => "cust_bill.*",
3274 'table' => 'cust_bill',
3275 'addl_from' => $addl_from,
3277 'extra_sql' => $extra_sql,
3278 'order_by' => $orderby,
3282 $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
3284 warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
3287 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
3288 foreach my $cust_bill ( @cust_bill ) {
3289 $cust_bill->$method();
3291 if ( $job ) { #progressbar foo
3293 if ( time - $min_sec > $last ) {
3294 my $error = $job->update_statustext(
3295 int( 100 * $num / scalar(@cust_bill) )
3297 die $error if $error;
3308 =head1 CLASS METHODS
3314 Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
3319 my ($class, $start, $end) = @_;
3321 $class->paid_sql($start, $end). ' - '.
3322 $class->credited_sql($start, $end);
3327 Returns an SQL fragment to retreive the net amount (charged minus credited).
3332 my ($class, $start, $end) = @_;
3333 'charged - '. $class->credited_sql($start, $end);
3338 Returns an SQL fragment to retreive the amount paid against this invoice.
3343 my ($class, $start, $end) = @_;
3344 $start &&= "AND cust_bill_pay._date <= $start";
3345 $end &&= "AND cust_bill_pay._date > $end";
3346 $start = '' unless defined($start);
3347 $end = '' unless defined($end);
3348 "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
3349 WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )";
3354 Returns an SQL fragment to retreive the amount credited against this invoice.
3359 my ($class, $start, $end) = @_;
3360 $start &&= "AND cust_credit_bill._date <= $start";
3361 $end &&= "AND cust_credit_bill._date > $end";
3362 $start = '' unless defined($start);
3363 $end = '' unless defined($end);
3364 "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
3365 WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
3370 Returns an SQL fragment to retrieve the due date of an invoice.
3371 Currently only supported on PostgreSQL.
3376 my $conf = new FS::Conf;
3380 cust_bill.invoice_terms,
3381 cust_main.invoice_terms,
3382 \''.($conf->config('invoice_default_terms') || '').'\'
3383 ), E\'Net (\\\\d+)\'
3385 ) * 86400 + cust_bill._date'
3388 =item search_sql_where HASHREF
3390 Class method which returns an SQL WHERE fragment to search for parameters
3391 specified in HASHREF. Valid parameters are
3397 List reference of start date, end date, as UNIX timestamps.
3407 List reference of charged limits (exclusive).
3411 List reference of charged limits (exclusive).
3415 flag, return open invoices only
3419 flag, return net invoices only
3423 =item newest_percust
3427 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
3431 sub search_sql_where {
3432 my($class, $param) = @_;
3434 warn "$me search_sql_where called with params: \n".
3435 join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
3441 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
3442 push @search, "cust_main.agentnum = $1";
3446 if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
3447 push @search, "cust_main.refnum = $1";
3451 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
3452 push @search, "cust_bill.custnum = $1";
3456 if ( $param->{'cust_classnum'} ) {
3457 my $classnums = $param->{'cust_classnum'};
3458 $classnums = [ $classnums ] if !ref($classnums);
3459 $classnums = [ grep /^\d+$/, @$classnums ];
3460 push @search, 'cust_main.classnum in ('.join(',',@$classnums).')'
3465 if ( $param->{_date} ) {
3466 my($beginning, $ending) = @{$param->{_date}};
3468 push @search, "cust_bill._date >= $beginning",
3469 "cust_bill._date < $ending";
3473 if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
3474 push @search, "cust_bill.invnum >= $1";
3476 if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
3477 push @search, "cust_bill.invnum <= $1";
3481 if ( $param->{charged} ) {
3482 my @charged = ref($param->{charged})
3483 ? @{ $param->{charged} }
3484 : ($param->{charged});
3486 push @search, map { s/^charged/cust_bill.charged/; $_; }
3490 my $owed_sql = FS::cust_bill->owed_sql;
3493 if ( $param->{owed} ) {
3494 my @owed = ref($param->{owed})
3495 ? @{ $param->{owed} }
3497 push @search, map { s/^owed/$owed_sql/; $_; }
3502 push @search, "0 != $owed_sql"
3503 if $param->{'open'};
3504 push @search, '0 != '. FS::cust_bill->net_sql
3508 push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
3509 if $param->{'days'};
3512 if ( $param->{'newest_percust'} ) {
3514 #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
3515 #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
3517 my @newest_where = map { my $x = $_;
3518 $x =~ s/\bcust_bill\./newest_cust_bill./g;
3521 grep ! /^cust_main./, @search;
3522 my $newest_where = scalar(@newest_where)
3523 ? ' AND '. join(' AND ', @newest_where)
3527 push @search, "cust_bill._date = (
3528 SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
3529 WHERE newest_cust_bill.custnum = cust_bill.custnum
3535 #promised_date - also has an option to accept nulls
3536 if ( $param->{promised_date} ) {
3537 my($beginning, $ending, $null) = @{$param->{promised_date}};
3539 push @search, "(( cust_bill.promised_date >= $beginning AND ".
3540 "cust_bill.promised_date < $ending )" .
3541 ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
3544 #agent virtualization
3545 my $curuser = $FS::CurrentUser::CurrentUser;
3546 if ( $curuser->username eq 'fs_queue'
3547 && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
3549 my $newuser = qsearchs('access_user', {
3550 'username' => $username,
3554 $curuser = $newuser;
3556 warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
3559 push @search, $curuser->agentnums_sql;
3561 join(' AND ', @search );
3573 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
3574 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base