package FS::cust_bill;
+use base qw( FS::cust_bill::Search FS::Template_Mixin
+ FS::cust_main_Mixin FS::Record
+ );
use strict;
-use vars qw( @ISA $DEBUG $me $conf $money_char );
-use vars qw( $invoice_lines @buf ); #yuck
+use vars qw( $DEBUG $me );
+ # but NOT $conf
+use Carp;
use Fcntl qw(:flock); #for spool_csv
-use List::Util qw(min max);
+use Cwd;
+use List::Util qw(min max sum);
use Date::Format;
-use Text::Template 1.20;
use File::Temp 0.14;
-use String::ShellQuote;
use HTML::Entities;
-use Locale::Country;
+use Storable qw( freeze thaw );
+use GD::Barcode;
use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
+use FS::Misc qw( send_fax do_print );
use FS::Record qw( qsearch qsearchs dbh );
-use FS::cust_main_Mixin;
-use FS::cust_main;
+use FS::cust_statement;
use FS::cust_bill_pkg;
use FS::cust_bill_pkg_display;
+use FS::cust_bill_pkg_detail;
use FS::cust_credit;
use FS::cust_pay;
use FS::cust_pkg;
use FS::cust_credit_bill;
use FS::pay_batch;
-use FS::cust_pay_batch;
-use FS::cust_bill_event;
use FS::cust_event;
use FS::part_pkg;
use FS::cust_bill_pay;
-use FS::cust_bill_pay_batch;
-use FS::part_bill_event;
use FS::payby;
-
-@ISA = qw( FS::cust_main_Mixin FS::Record );
+use FS::bill_batch;
+use FS::cust_bill_batch;
+use FS::cust_bill_pay_pkg;
+use FS::cust_credit_bill_pkg;
+use FS::discount_plan;
+use FS::cust_bill_void;
+use FS::L10N;
$DEBUG = 0;
$me = '[FS::cust_bill]';
-#ask FS::UID to run this stuff for us later
-FS::UID->install_callback( sub {
- $conf = new FS::Conf;
- $money_char = $conf->config('money_char') || '$';
-} );
-
=head1 NAME
FS::cust_bill - Object methods for cust_bill records
$tax_amount = $record->tax;
@lines = $cust_bill->print_text;
- @lines = $cust_bill->print_text $time;
+ @lines = $cust_bill->print_text('time' => $time);
=head1 DESCRIPTION
(see L<FS::cust_bill_pkg>). FS::cust_bill inherits from FS::Record. The
following fields are currently supported:
+Regular fields
+
=over 4
=item invnum - primary key (assigned automatically for new invoices)
=item charged - amount of this invoice
-=item printed - deprecated
+=item invoice_terms - optional terms override for this specific invoice
+
+=back
+
+Deprecated fields
+
+=over 4
+
+=item billing_balance - the customer's balance immediately before generating
+this invoice. DEPRECATED. Use the L<FS::cust_main/balance_date> method
+to determine the customer's balance at a specific time.
+
+=item previous_balance - the customer's balance immediately after generating
+the invoice before this one. DEPRECATED.
+
+=item printed - formerly used to track the number of times an invoice had
+been printed; no longer used.
+
+=back
+
+Specific use cases
+
+=over 4
=item closed - books closed flag, empty or `Y'
+=item statementnum - invoice aggregation (see L<FS::cust_statement>)
+
+=item agent_invid - legacy invoice number
+
+=item promised_date - customer promised payment date, for collection
+
+=item pending - invoice is still being generated, empty or 'Y'
+
=back
=head1 METHODS
=cut
sub table { 'cust_bill'; }
+sub template_conf { 'invoice_'; }
+
+sub has_sections {
+ my $self = shift;
+ my $agentnum = $self->cust_main->agentnum;
+ my $tc = $self->template_conf;
-sub cust_linked { $_[0]->cust_main_custnum; }
+ $self->conf->exists($tc.'sections', $agentnum) ||
+ $self->conf->exists($tc.'sections_by_location', $agentnum);
+}
+
+# should be the ONLY occurrence of "Invoice" in invoice rendering code.
+# (except email_subject and invnum_date_pretty)
+sub notice_name {
+ my $self = shift;
+ $self->conf->config('notice_name') || 'Invoice'
+}
+
+sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum }
sub cust_unlinked_msg {
my $self = shift;
"WARNING: can't find cust_main.custnum ". $self->custnum.
Adds this invoice to the database ("Posts" the invoice). If there is an error,
returns the error, otherwise returns false.
-=item delete
+=cut
+
+sub insert {
+ my $self = shift;
+ warn "$me insert called\n" if $DEBUG;
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
+ local $SIG{TERM} = 'IGNORE';
+ local $SIG{TSTP} = 'IGNORE';
+ local $SIG{PIPE} = 'IGNORE';
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ my $error = $self->SUPER::insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ if ( $self->get('cust_bill_pkg') ) {
+ foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
+ $cust_bill_pkg->invnum($self->invnum);
+ my $error = $cust_bill_pkg->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't create invoice line item: $error";
+ }
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
-This method now works but you probably shouldn't use it. Instead, apply a
-credit against the invoice.
+}
-Using this method to delete invoices outright is really, really bad. There
-would be no record you ever posted this invoice, and there are no check to
-make sure charged = 0 or that there are no associated cust_bill_pkg records.
+=item void
-Really, don't use it.
+Voids this invoice: deletes the invoice and adds a record of the voided invoice
+to the FS::cust_bill_void table (and related tables starting from
+FS::cust_bill_pkg_void).
=cut
-sub delete {
+sub void {
+ my $self = shift;
+ my $reason = scalar(@_) ? shift : '';
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
+ local $SIG{TERM} = 'IGNORE';
+ local $SIG{TSTP} = 'IGNORE';
+ local $SIG{PIPE} = 'IGNORE';
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ my $cust_bill_void = new FS::cust_bill_void ( {
+ map { $_ => $self->get($_) } $self->fields
+ } );
+ $cust_bill_void->reason($reason);
+ my $error = $cust_bill_void->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ my $error = $cust_bill_pkg->void($reason);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ $error = $self->_delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ '';
+
+}
+
+# removed docs entirely and renamed method to _delete to further indicate it is
+# internal-only and discourage use
+#
+# =item delete
+#
+# DO NOT USE THIS METHOD. Instead, apply a credit against the invoice, or use
+# the B<void> method.
+#
+# This is only for internal use by V<void>, which is what you should be using.
+#
+# DO NOT USE THIS METHOD. Whatever reason you think you have is almost certainly
+# wrong. Use B<void>, that's what it is for. Really. This means you.
+#
+# =cut
+
+sub _delete {
my $self = shift;
return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
- $self->SUPER::delete(@_);
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
+ local $SIG{TERM} = 'IGNORE';
+ local $SIG{TSTP} = 'IGNORE';
+ local $SIG{PIPE} = 'IGNORE';
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ foreach my $table (qw(
+ cust_credit_bill
+ cust_bill_pay_batch
+ cust_bill_pay
+ cust_bill_batch
+ cust_bill_pkg
+ )) {
+ #cust_event # problematic
+ #cust_pay_batch # unnecessary
+
+ foreach my $linked ( $self->$table() ) {
+ my $error = $linked->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ }
+
+ my $error = $self->SUPER::delete(@_);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ '';
+
}
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
-Replaces the OLD_RECORD with this one in the database. If there is an error,
-returns the error, otherwise returns false.
+You can, but probably shouldn't modify invoices...
-Only printed may be changed. printed is normally updated by calling the
-collect method of a customer object (see L<FS::cust_main>).
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
=cut
sub replace_check {
my( $new, $old ) = ( shift, shift );
- return "Can't change custnum!" unless $old->custnum == $new->custnum;
+ return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
#return "Can't change _date!" unless $old->_date eq $new->_date;
- return "Can't change _date!" unless $old->_date == $new->_date;
- return "Can't change charged!" unless $old->charged == $new->charged
- || $old->charged == 0;
+ return "Can't change _date" unless $old->_date == $new->_date;
+ return "Can't change charged" unless $old->charged == $new->charged
+ || $old->pending eq 'Y'
+ || $old->charged == 0
+ || $new->{'Hash'}{'cc_surcharge_replace_hack'};
'';
}
+
+=item add_cc_surcharge
+
+Giant hack
+
+=cut
+
+sub add_cc_surcharge {
+ my ($self, $pkgnum, $amount) = (shift, shift, shift);
+
+ my $error;
+ my $cust_bill_pkg = new FS::cust_bill_pkg({
+ 'invnum' => $self->invnum,
+ 'pkgnum' => $pkgnum,
+ 'setup' => $amount,
+ });
+ $error = $cust_bill_pkg->insert;
+ return $error if $error;
+
+ $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
+ $self->charged($self->charged+$amount);
+ $error = $self->replace;
+ return $error if $error;
+
+ $self->apply_payments_and_credits;
+}
+
+
=item check
Checks all fields to make sure this is a valid invoice. If there is an error,
my $error =
$self->ut_numbern('invnum')
- || $self->ut_number('custnum')
+ || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
|| $self->ut_numbern('_date')
|| $self->ut_money('charged')
|| $self->ut_numbern('printed')
|| $self->ut_enum('closed', [ '', 'Y' ])
+ || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
+ || $self->ut_numbern('agent_invid') #varchar?
+ || $self->ut_flag('pending')
;
return $error if $error;
- return "Unknown customer"
- unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
-
$self->_date(time) unless $self->_date;
$self->printed(0) if $self->printed eq '';
$self->SUPER::check;
}
+=item display_invnum
+
+Returns the displayed invoice number for this invoice: agent_invid if
+cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
+
+=cut
+
+sub display_invnum {
+ my $self = shift;
+ if ( $self->agent_invid
+ && FS::Conf->new->exists('cust_bill-default_agent_invid') ) {
+ return $self->agent_invid;
+ } else {
+ return $self->invnum;
+ }
+}
+
+=item previous_bill
+
+Returns the customer's last invoice before this one.
+
+=cut
+
+sub previous_bill {
+ my $self = shift;
+ if ( !$self->get('previous_bill') ) {
+ $self->set('previous_bill', qsearchs({
+ 'table' => 'cust_bill',
+ 'hashref' => { 'custnum' => $self->custnum,
+ '_date' => { op=>'<', value=>$self->_date } },
+ 'order_by' => 'ORDER BY _date DESC LIMIT 1',
+ }) );
+ }
+ $self->get('previous_bill');
+}
+
=item previous
Returns a list consisting of the total previous balance for this customer,
sub previous {
my $self = shift;
- my $total = 0;
- my @cust_bill = sort { $a->_date <=> $b->_date }
- grep { $_->owed != 0 && $_->_date < $self->_date }
- qsearch( 'cust_bill', { 'custnum' => $self->custnum } )
- ;
- foreach ( @cust_bill ) { $total += $_->owed; }
- $total, @cust_bill;
+ # simple memoize; we use this a lot
+ if (!$self->get('previous')) {
+ my $total = 0;
+ my @cust_bill = sort { $a->_date <=> $b->_date }
+ grep { $_->owed != 0 }
+ qsearch( 'cust_bill', { 'custnum' => $self->custnum,
+ #'_date' => { op=>'<', value=>$self->_date },
+ 'invnum' => { op=>'<', value=>$self->invnum },
+ } )
+ ;
+ foreach ( @cust_bill ) { $total += $_->owed; }
+ $self->set('previous', [$total, @cust_bill]);
+ }
+ return @{ $self->get('previous') };
+}
+
+=item enable_previous
+
+Whether to show the 'Previous Charges' section when printing this invoice.
+The negation of the 'disable_previous_balance' config setting.
+
+=cut
+
+sub enable_previous {
+ my $self = shift;
+ my $agentnum = $self->cust_main->agentnum;
+ !$self->conf->exists('disable_previous_balance', $agentnum);
}
=item cust_bill_pkg
qsearch(
{ 'table' => 'cust_bill_pkg',
'hashref' => { 'invnum' => $self->invnum },
+ 'order_by' => 'ORDER BY billpkgnum', #important? otherwise we could use
+ # the AUTLOADED FK search. or should
+ # that default to ORDER by the pkey?
+ }
+ );
+}
+
+=item cust_bill_pkg_pkgnum PKGNUM
+
+Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
+specified pkgnum.
+
+=cut
+
+sub cust_bill_pkg_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ qsearch(
+ { 'table' => 'cust_bill_pkg',
+ 'hashref' => { 'invnum' => $self->invnum,
+ 'pkgnum' => $pkgnum,
+ },
'order_by' => 'ORDER BY billpkgnum',
}
);
sub cust_pkg {
my $self = shift;
- my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg;
+ my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
+ $self->cust_bill_pkg;
my %saw = ();
grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
}
+=item no_auto
+
+Returns true if any of the packages (or their definitions) corresponding to the
+line items for this invoice have the no_auto flag set.
+
+=cut
+
+sub no_auto {
+ my $self = shift;
+ grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
+}
+
=item open_cust_bill_pkg
Returns the open line items for this invoice.
@open;
}
-=item cust_bill_event
-
-Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
-
-=cut
-
-sub cust_bill_event {
- my $self = shift;
- qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
-}
-
-=item num_cust_bill_event
-
-Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
-
-=cut
-
-sub num_cust_bill_event {
- my $self = shift;
- my $sql =
- "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
- my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
- $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
- $sth->fetchrow_arrayref->[0];
-}
-
=item cust_event
Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
Returns the customer (see L<FS::cust_main>) for this invoice.
+=item suspend
+
+Suspends all unsuspended packages (see L<FS::cust_pkg>) for this invoice
+
+Returns a list: an empty list on success or a list of errors.
+
=cut
-sub cust_main {
+sub suspend {
my $self = shift;
- qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+
+ grep { $_->suspend(@_) }
+ grep {! $_->getfield('cancel') }
+ $self->cust_pkg;
+
}
=item cust_suspend_if_balance_over AMOUNT
}
}
-=item cust_credit
-
-Depreciated. See the cust_credited method.
+=item cancel
- #Returns a list consisting of the total previous credited (see
- #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
- #outstanding credits (FS::cust_credit objects).
+Cancel the packages on this invoice. Largely similar to the cust_main version, but does not bother yet with banned payment options
=cut
-sub cust_credit {
- use Carp;
- croak "FS::cust_bill->cust_credit depreciated; see ".
- "FS::cust_bill->cust_credit_bill";
- #my $self = shift;
- #my $total = 0;
- #my @cust_credit = sort { $a->_date <=> $b->_date }
- # grep { $_->credited != 0 && $_->_date < $self->_date }
- # qsearch('cust_credit', { 'custnum' => $self->custnum } )
- #;
- #foreach (@cust_credit) { $total += $_->credited; }
- #$total, @cust_credit;
-}
+sub cancel {
+ my( $self, %opt ) = @_;
-=item cust_pay
+ warn "$me cancel called on cust_bill ". $self->invnum . " with options ".
+ join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
+ if $DEBUG;
-Depreciated. See the cust_bill_pay method.
+ return ( 'Access denied' )
+ unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
-#Returns all payments (see L<FS::cust_pay>) for this invoice.
+ my @pkgs = $self->cust_pkg;
-=cut
+ if ( !$opt{nobill} && $self->conf->exists('bill_usage_on_cancel') ) {
+ $opt{nobill} = 1;
+ my $error = $self->cust_main->bill( pkg_list => [ @pkgs ], cancel => 1 );
+ warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
+ if $error;
+ }
-sub cust_pay {
- use Carp;
- croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
- #my $self = shift;
- #sort { $a->_date <=> $b->_date }
- # qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
- #;
+ grep { $_ }
+ map { $_->cancel(%opt) }
+ grep { ! $_->getfield('cancel') }
+ @pkgs;
}
=item cust_bill_pay
sub cust_bill_pay {
my $self = shift;
+ map { $_ } #return $self->num_cust_bill_pay unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
}
=item cust_credited
+=item cust_credit_bill
+
Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
=cut
sub cust_credited {
my $self = shift;
+ map { $_ } #return $self->num_cust_credit_bill unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
;
}
+sub cust_credit_bill {
+ shift->cust_credited(@_);
+}
+
+#=item cust_bill_pay_pkgnum PKGNUM
+#
+#Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
+#with matching pkgnum.
+#
+#=cut
+#
+#sub cust_bill_pay_pkgnum {
+# my( $self, $pkgnum ) = @_;
+# map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
+# sort { $a->_date <=> $b->_date }
+# qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
+# 'pkgnum' => $pkgnum,
+# }
+# );
+#}
+
+=item cust_bill_pay_pkg PKGNUM
+
+Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
+applied against the matching pkgnum.
+
+=cut
+
+sub cust_bill_pay_pkg {
+ my( $self, $pkgnum ) = @_;
+
+ qsearch({
+ 'select' => 'cust_bill_pay_pkg.*',
+ 'table' => 'cust_bill_pay_pkg',
+ 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
+ ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
+ 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
+ " AND cust_bill_pkg.pkgnum = $pkgnum",
+ });
+
+}
+
+#=item cust_credited_pkgnum PKGNUM
+#
+#=item cust_credit_bill_pkgnum PKGNUM
+#
+#Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
+#with matching pkgnum.
+#
+#=cut
+#
+#sub cust_credited_pkgnum {
+# my( $self, $pkgnum ) = @_;
+# map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
+# sort { $a->_date <=> $b->_date }
+# qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
+# 'pkgnum' => $pkgnum,
+# }
+# );
+#}
+#
+#sub cust_credit_bill_pkgnum {
+# shift->cust_credited_pkgnum(@_);
+#}
+
+=item cust_credit_bill_pkg PKGNUM
+
+Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
+applied against the matching pkgnum.
+
+=cut
+
+sub cust_credit_bill_pkg {
+ my( $self, $pkgnum ) = @_;
+
+ qsearch({
+ 'select' => 'cust_credit_bill_pkg.*',
+ 'table' => 'cust_credit_bill_pkg',
+ 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
+ ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
+ 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
+ " AND cust_bill_pkg.pkgnum = $pkgnum",
+ });
+
+}
+
+=item cust_bill_batch
+
+Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
+
+=cut
+
+sub cust_bill_batch {
+ my $self = shift;
+ qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
+}
+
+=item discount_plans
+
+Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
+hash keyed by term length.
+
+=cut
+
+sub discount_plans {
+ my $self = shift;
+ FS::discount_plan->all($self);
+}
+
=item tax
Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
$balance;
}
-=item apply_payments_and_credits
+sub owed_pkgnum {
+ my( $self, $pkgnum ) = @_;
+
+ #my $balance = $self->charged;
+ my $balance = 0;
+ $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
+
+ $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum);
+ $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum);
+
+ $balance = sprintf( "%.2f", $balance);
+ $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
+ $balance;
+}
+
+=item hide
+
+Returns true if this invoice should be hidden. See the
+selfservice-hide_invoices-taxclass configuraiton setting.
=cut
-sub apply_payments_and_credits {
+sub hide {
my $self = shift;
+ my $conf = $self->conf;
+ my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+ or return '';
+ my @cust_bill_pkg = $self->cust_bill_pkg;
+ my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+ ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
- local $SIG{HUP} = 'IGNORE';
- local $SIG{INT} = 'IGNORE';
- local $SIG{QUIT} = 'IGNORE';
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
+
+Applies unapplied payments and credits to this invoice.
+Payments with the no_auto_apply flag set will not be applied.
+
+A hash of optional arguments may be passed. Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub apply_payments_and_credits {
+ my( $self, %options ) = @_;
+ my $conf = $self->conf;
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
local $SIG{TERM} = 'IGNORE';
local $SIG{TSTP} = 'IGNORE';
local $SIG{PIPE} = 'IGNORE';
$self->select_for_update; #mutex
- my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
+ my @payments = grep { $_->unapplied > 0 }
+ grep { !$_->no_auto_apply }
+ $self->cust_main->cust_pay;
my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
+ if ( $conf->exists('pkg-balances') ) {
+ # limit @payments & @credits to those w/ a pkgnum grepped from $self
+ my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
+ @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+ @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+ }
+
while ( $self->owed > 0 and ( @payments || @credits ) ) {
my $app = '';
die "guru meditation #12 and 35";
}
+ my $unapp_amount;
if ( $app eq 'pay' ) {
my $payment = shift @payments;
-
- $app = new FS::cust_bill_pay {
- 'paynum' => $payment->paynum,
- 'amount' => sprintf('%.2f', min( $payment->unapplied, $self->owed ) ),
- };
+ $unapp_amount = $payment->unapplied;
+ $app = new FS::cust_bill_pay { 'paynum' => $payment->paynum };
+ $app->pkgnum( $payment->pkgnum )
+ if $conf->exists('pkg-balances') && $payment->pkgnum;
} elsif ( $app eq 'credit' ) {
my $credit = shift @credits;
-
- $app = new FS::cust_credit_bill {
- 'crednum' => $credit->crednum,
- 'amount' => sprintf('%.2f', min( $credit->credited, $self->owed ) ),
- };
+ $unapp_amount = $credit->credited;
+ $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
+ $app->pkgnum( $credit->pkgnum )
+ if $conf->exists('pkg-balances') && $credit->pkgnum;
} else {
die "guru meditation #12 and 35";
}
+ my $owed;
+ if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
+ warn "owed_pkgnum ". $app->pkgnum;
+ $owed = $self->owed_pkgnum($app->pkgnum);
+ } else {
+ $owed = $self->owed;
+ }
+ next unless $owed > 0;
+
+ warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
+ $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
+
$app->invnum( $self->invnum );
- my $error = $app->insert;
+ my $error = $app->insert(%options);
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return "Error inserting ". $app->table. " record: $error";
}
-=item generate_email OPTION => VALUE ...
-
-Options:
-
-=over 4
-
-=item from
-
-sender address, required
+=item send HASHREF
-=item tempate
+Sends this invoice to the destinations configured for this customer: sends
+email, prints and/or faxes. See L<FS::cust_main_invoice>.
-alternate template name, optional
+Options can be passed as a hashref. Positional parameters are no longer
+allowed.
-=item print_text
+I<template>: a suffix for alternate invoices
-text attachment arrayref, optional
+I<agentnum>: obsolete, now does nothing.
-=item subject
+I<from> overrides the default email invoice From: address.
-email subject, optional
+I<amount>: obsolete, does nothing
-=back
+I<notice_name> overrides "Invoice" as the name of the sent document
+(templates from 10/2009 or newer required).
-Returns an argument list to be passed to L<FS::Misc::send_email>.
+I<lpr> overrides the system 'lpr' option as the command to print a document
+from standard input.
=cut
-use MIME::Entity;
-
-sub generate_email {
-
+sub send {
my $self = shift;
- my %args = @_;
+ my $opt = ref($_[0]) ? $_[0] : +{ @_ };
+ my $conf = $self->conf;
- my $me = '[FS::cust_bill::generate_email]';
-
- my %return = (
- 'from' => $args{'from'},
- 'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
- );
-
- if (ref($args{'to'}) eq 'ARRAY') {
- $return{'to'} = $args{'to'};
- } else {
- $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
- $self->cust_main->invoicing_list
- ];
- }
-
- if ( $conf->exists('invoice_html') ) {
-
- warn "$me creating HTML/text multipart message"
- if $DEBUG;
-
- $return{'nobody'} = 1;
-
- my $alternative = build MIME::Entity
- 'Type' => 'multipart/alternative',
- 'Encoding' => '7bit',
- 'Disposition' => 'inline'
- ;
-
- my $data;
- if ( $conf->exists('invoice_email_pdf')
- and scalar($conf->config('invoice_email_pdf_note')) ) {
-
- warn "$me using 'invoice_email_pdf_note' in multipart message"
- if $DEBUG;
- $data = [ map { $_ . "\n" }
- $conf->config('invoice_email_pdf_note')
- ];
-
- } else {
-
- warn "$me not using 'invoice_email_pdf_note' in multipart message"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $data = $args{'print_text'};
- } else {
- $data = [ $self->print_text('', $args{'template'}) ];
- }
-
- }
-
- $alternative->attach(
- 'Type' => 'text/plain',
- #'Encoding' => 'quoted-printable',
- 'Encoding' => '7bit',
- 'Data' => $data,
- 'Disposition' => 'inline',
- );
-
- $args{'from'} =~ /\@([\w\.\-]+)/;
- my $from = $1 || 'example.com';
- my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
-
- my $logo;
- my $agentnum = $self->cust_main->agentnum;
- if ( defined($args{'template'}) && length($args{'template'})
- && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
- )
- {
- $logo = 'logo_'. $args{'template'}. '.png';
- } else {
- $logo = "logo.png";
- }
- my $image_data = $conf->config_binary( $logo, $agentnum);
-
- my $image = build MIME::Entity
- 'Type' => 'image/png',
- 'Encoding' => 'base64',
- 'Data' => $image_data,
- 'Filename' => 'logo.png',
- 'Content-ID' => "<$content_id>",
- ;
-
- $alternative->attach(
- 'Type' => 'text/html',
- 'Encoding' => 'quoted-printable',
- 'Data' => [ '<html>',
- ' <head>',
- ' <title>',
- ' '. encode_entities($return{'subject'}),
- ' </title>',
- ' </head>',
- ' <body bgcolor="#e8e8e8">',
- $self->print_html('', $args{'template'}, $content_id),
- ' </body>',
- '</html>',
- ],
- 'Disposition' => 'inline',
- #'Filename' => 'invoice.pdf',
- );
-
- if ( $conf->exists('invoice_email_pdf') ) {
-
- #attaching pdf too:
- # multipart/mixed
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
- # application/pdf
-
- my $related = build MIME::Entity 'Type' => 'multipart/related',
- 'Encoding' => '7bit';
-
- #false laziness w/Misc::send_email
- $related->head->replace('Content-type',
- $related->mime_type.
- '; boundary="'. $related->head->multipart_boundary. '"'.
- '; type=multipart/alternative'
- );
+ my $cust_main = $self->cust_main;
- $related->add_part($alternative);
+ my @invoicing_list = $cust_main->invoicing_list;
- $related->add_part($image);
+ $self->email($opt)
+ if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
+ && ! $cust_main->invoice_noemail;
- my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'});
+ $self->print($opt)
+ if grep { $_ eq 'POST' } @invoicing_list; #postal
- $return{'mimeparts'} = [ $related, $pdf ];
+ #this has never been used post-$ORIGINAL_ISP afaik
+ $self->fax_invoice($opt)
+ if grep { $_ eq 'FAX' } @invoicing_list; #fax
- } else {
+ '';
- #no other attachment:
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
+}
- $return{'content-type'} = 'multipart/related';
- $return{'mimeparts'} = [ $alternative, $image ];
- $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
- #$return{'disposition'} = 'inline';
+sub email {
+ my $self = shift;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ die ref($self). '->email called with positional parameters';
+ }
- }
-
- } else {
+ my $conf = $self->conf;
- if ( $conf->exists('invoice_email_pdf') ) {
- warn "$me creating PDF attachment"
- if $DEBUG;
+ my $from = delete $opt->{from};
- #mime parts arguments a la MIME::Entity->build().
- $return{'mimeparts'} = [
- { $self->mimebuild_pdf('', $args{'template'}) }
- ];
- }
-
- if ( $conf->exists('invoice_email_pdf')
- and scalar($conf->config('invoice_email_pdf_note')) ) {
+ # this is where we set the From: address
+ $from ||= $self->_agent_invoice_from || #XXX should go away
+ $conf->invoice_from_full( $self->cust_main->agentnum );
- warn "$me using 'invoice_email_pdf_note'"
- if $DEBUG;
- $return{'body'} = [ map { $_ . "\n" }
- $conf->config('invoice_email_pdf_note')
- ];
+ my @invoicing_list = $self->cust_main->invoicing_list_emailonly;
+ if ( ! @invoicing_list ) { #no recipients
+ if ( $conf->exists('cust_bill-no_recipients-error') ) {
+ die 'No recipients for customer #'. $self->custnum;
} else {
-
- warn "$me not using 'invoice_email_pdf_note'"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $return{'body'} = $args{'print_text'};
- } else {
- $return{'body'} = [ $self->print_text('', $args{'template'}) ];
- }
-
+ #default: better to notify this person than silence
+ @invoicing_list = ($from);
}
-
}
- %return;
-
-}
-
-=item mimebuild_pdf
-
-Returns a list suitable for passing to MIME::Entity->build(), representing
-this invoice as PDF attachment.
-
-=cut
+ $self->SUPER::email( {
+ 'from' => $from,
+ 'to' => \@invoicing_list,
+ %$opt,
+ });
-sub mimebuild_pdf {
- my $self = shift;
- (
- 'Type' => 'application/pdf',
- 'Encoding' => 'base64',
- 'Data' => [ $self->print_pdf(@_) ],
- 'Disposition' => 'attachment',
- 'Filename' => 'invoice.pdf',
- );
}
-=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
-
-Sends this invoice to the destinations configured for this customer: sends
-email, prints and/or faxes. See L<FS::cust_main_invoice>.
-
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
-
-AGENTNUM, if specified, means that this invoice will only be sent for customers
-of the specified agent or agent(s). AGENTNUM can be a scalar agentnum (for a
-single agent) or an arrayref of agentnums.
-
-INVOICE_FROM, if specified, overrides the default email invoice From: address.
-
-AMOUNT, if specified, only sends the invoice if the total amount owed on this
-invoice and all older invoices is greater than the specified amount.
-
-=cut
-
-sub queueable_send {
+#this stays here for now because its explicitly used as
+# FS::cust_bill::queueable_email
+sub queueable_email {
my %opt = @_;
my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
or die "invalid invoice number: " . $opt{invnum};
- my @args = ( $opt{template}, $opt{agentnum} );
- push @args, $opt{invoice_from}
- if exists($opt{invoice_from}) && $opt{invoice_from};
-
- my $error = $self->send( @args );
- die $error if $error;
-
-}
-
-sub send {
- my $self = shift;
- my $template = scalar(@_) ? shift : '';
- if ( scalar(@_) && $_[0] ) {
- my $agentnums = ref($_[0]) ? shift : [ shift ];
- return 'N/A' unless grep { $_ == $self->cust_main->agentnum } @$agentnums;
+ if ( $opt{mode} ) {
+ $self->set('mode', $opt{mode});
}
- my $invoice_from =
- scalar(@_)
- ? shift
- : ( $self->_agent_invoice_from || #XXX should go away
- $conf->config('invoice_from', $self->cust_main->agentnum )
- );
-
- my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0;
-
- return ''
- unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
-
- my @invoicing_list = $self->cust_main->invoicing_list;
-
- #$self->email_invoice($template, $invoice_from)
- $self->email($template, $invoice_from)
- if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
-
- #$self->print_invoice($template)
- $self->print($template)
- if grep { $_ eq 'POST' } @invoicing_list; #postal
-
- $self->fax_invoice($template)
- if grep { $_ eq 'FAX' } @invoicing_list; #fax
-
- '';
-
-}
-
-=item email [ TEMPLATENAME [ , INVOICE_FROM ] ]
-
-Emails this invoice.
-
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
-
-INVOICE_FROM, if specified, overrides the default email invoice From: address.
-
-=cut
-
-sub queueable_email {
- my %opt = @_;
-
- my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
- or die "invalid invoice number: " . $opt{invnum};
-
- my @args = ( $opt{template} );
- push @args, $opt{invoice_from}
- if exists($opt{invoice_from}) && $opt{invoice_from};
+ my %args = map {$_ => $opt{$_}}
+ grep { $opt{$_} }
+ qw( from notice_name no_coupon template );
- my $error = $self->email( @args );
+ my $error = $self->email( \%args );
die $error if $error;
}
-#sub email_invoice {
-sub email {
- my $self = shift;
- my $template = scalar(@_) ? shift : '';
- my $invoice_from =
- scalar(@_)
- ? shift
- : ( $self->_agent_invoice_from || #XXX should go away
- $conf->config('invoice_from', $self->cust_main->agentnum )
- );
-
-
- my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
- $self->cust_main->invoicing_list;
-
- #better to notify this person than silence
- @invoicing_list = ($invoice_from) unless @invoicing_list;
-
- my $subject = $self->email_subject($template);
-
- my $error = send_email(
- $self->generate_email(
- 'from' => $invoice_from,
- 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
- 'subject' => $subject,
- 'template' => $template,
- )
- );
- die "can't email invoice: $error\n" if $error;
- #die "$error\n" if $error;
-
-}
-
sub email_subject {
my $self = shift;
+ my $conf = $self->conf;
#my $template = scalar(@_) ? shift : '';
#per-template?
eval qq("$subject");
}
-=item lpr_data [ TEMPLATENAME ]
+=item lpr_data HASHREF
Returns the postscript or plaintext for this invoice as an arrayref.
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options must be passed as a hashref. Positional parameters are no longer
+allowed.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
=cut
sub lpr_data {
- my( $self, $template) = @_;
- $conf->exists('invoice_latex')
- ? [ $self->print_ps('', $template) ]
- : [ $self->print_text('', $template) ];
+ my $self = shift;
+ my $conf = $self->conf;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ # nobody does this anyway
+ die "FS::cust_bill::lpr_data called with positional parameters";
+ }
+
+ my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
+ [ $self->$method( $opt ) ];
}
-=item print [ TEMPLATENAME ]
+=item print HASHREF
Prints this invoice.
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options must be passed as a hashref.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
=cut
-#sub print_invoice {
sub print {
my $self = shift;
- my $template = scalar(@_) ? shift : '';
+ return if $self->hide;
+ my $conf = $self->conf;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ die "FS::cust_bill::print called with positional parameters";
+ }
- do_print $self->lpr_data($template);
+ my $lpr = delete $opt->{lpr};
+ if($conf->exists('invoice_print_pdf')) {
+ # Add the invoice to the current batch.
+ $self->batch_invoice($opt);
+ }
+ else {
+ do_print(
+ $self->lpr_data($opt),
+ 'agentnum' => $self->cust_main->agentnum,
+ 'lpr' => $lpr,
+ );
+ }
}
-=item fax_invoice [ TEMPLATENAME ]
+=item fax_invoice HASHREF
Faxes this invoice.
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options must be passed as a hashref.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
=cut
sub fax_invoice {
my $self = shift;
- my $template = scalar(@_) ? shift : '';
+ return if $self->hide;
+ my $conf = $self->conf;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ die "FS::cust_bill::fax_invoice called with positional parameters";
+ }
die 'FAX invoice destination not (yet?) supported with plain text invoices.'
unless $conf->exists('invoice_latex');
my $dialstring = $self->cust_main->getfield('fax');
#Check $dialstring?
- my $error = send_fax( 'docdata' => $self->lpr_data($template),
+ my $error = send_fax( 'docdata' => $self->lpr_data($opt),
'dialstring' => $dialstring,
);
die $error if $error;
}
+=item batch_invoice [ HASHREF ]
+
+Place this invoice into the open batch (see C<FS::bill_batch>). If there
+isn't an open batch, one will be created.
+
+HASHREF may contain any options to be passed to C<print_pdf>.
+
+=cut
+
+sub batch_invoice {
+ my ($self, $opt) = @_;
+ my $bill_batch = $self->get_open_bill_batch;
+ my $cust_bill_batch = FS::cust_bill_batch->new({
+ batchnum => $bill_batch->batchnum,
+ invnum => $self->invnum,
+ });
+ return $cust_bill_batch->insert($opt);
+}
+
+=item get_open_batch
+
+Returns the currently open batch as an FS::bill_batch object, creating a new
+one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
+enabled)
+
+=cut
+
+sub get_open_bill_batch {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $hashref = { status => 'O' };
+ $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
+ ? $self->cust_main->agentnum
+ : '';
+ my $batch = qsearchs('bill_batch', $hashref);
+ return $batch if $batch;
+ $batch = FS::bill_batch->new($hashref);
+ my $error = $batch->insert;
+ die $error if $error;
+ return $batch;
+}
+
=item ftp_invoice [ TEMPLATENAME ]
Sends this invoice data via FTP.
sub ftp_invoice {
my $self = shift;
+ my $conf = $self->conf;
my $template = scalar(@_) ? shift : '';
$self->send_csv(
sub spool_invoice {
my $self = shift;
+ my $conf = $self->conf;
my $template = scalar(@_) ? shift : '';
$self->spool_csv(
);
}
-=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
-
-Like B<send>, but only sends the invoice if it is the newest open invoice for
-this customer.
-
-=cut
-
-sub send_if_newest {
- my $self = shift;
-
- return ''
- if scalar(
- grep { $_->owed > 0 }
- qsearch('cust_bill', {
- 'custnum' => $self->custnum,
- #'_date' => { op=>'>', value=>$self->_date },
- 'invnum' => { op=>'>', value=>$self->invnum },
- } )
- );
-
- $self->send(@_);
-}
-
=item send_csv OPTION => VALUE, ...
Sends invoice as a CSV data-file to a remote host with the specified protocol.
my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
mkdir $spooldir, 0700 unless -d $spooldir;
+ # don't localize dates here, they're a defined format
my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
my $file = "$spooldir/$tracctnum.csv";
=over 4
-=item format - 'default' or 'billco'
+=item format - any of FS::Misc::::Invoicing::spool_formats
-=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
+customer has the corresponding invoice destinations set (see
+L<FS::cust_main_invoice>).
-=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+=item agent_spools - if set to a true value, will spool to per-agent files
+rather than a single global file
-=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+=item upload_targetnum - if set to a target (see L<FS::upload_target>), will
+append to that spool. L<FS::Cron::upload> will then send the spool file to
+that destination.
+
+=item balanceover - if set, only spools the invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
+
+=item time - the "current time". Controls the printing of past due messages
+in the ICS format.
=back
sub spool_csv {
my($self, %opt) = @_;
+ my $time = $opt{'time'} || time;
my $cust_main = $self->cust_main;
if ( $opt{'dest'} ) {
my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
mkdir $spooldir, 0700 unless -d $spooldir;
- my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
+ my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', $time);
- my $file =
- "$spooldir/".
- ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
- ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
- '.csv';
+ my $file;
+ if ( $opt{'agent_spools'} ) {
+ $file = 'agentnum'.$cust_main->agentnum;
+ } else {
+ $file = 'spool';
+ }
+
+ if ( $opt{'upload_targetnum'} ) {
+ $spooldir .= '/target'.$opt{'upload_targetnum'};
+ mkdir $spooldir, 0700 unless -d $spooldir;
+ } # otherwise it just goes into export.xxx/cust_bill
+
+ if ( lc($opt{'format'}) eq 'billco' ) {
+ $file .= '-header';
+ }
+
+ $file = "$spooldir/$file.csv";
- my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
+ my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum);
open(CSV, ">>$file") or die "can't open $file: $!";
flock(CSV, LOCK_EX);
flock(CSV, LOCK_UN);
close CSV;
- $file =
- "$spooldir/".
- ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
- '-detail.csv';
+ $file =~ s/-header.csv$/-detail.csv/;
open(CSV,">>$file") or die "can't open $file: $!";
flock(CSV, LOCK_EX);
seek(CSV, 0, 2);
}
- print CSV $detail;
+ print CSV $detail if defined($detail);
flock(CSV, LOCK_UN);
close CSV;
Options are:
-format - 'default' or 'billco'
+format - 'default', 'billco', 'oneline', 'bridgestone'
Returns a list consisting of two scalars. The first is a single line of CSV
header information for this invoice. The second is one or more lines of CSV
If I<format> is not specified or "default", the fields of the CSV file are as
follows:
-record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+record_type, invnum, custnum, _date, charged, first, last, company, address1,
+address2, city, state, zip, country, pkg, setup, recur, sdate, edate
=over 4
9 | Grouping Code | GROUP | CHAR | 2
10 | User Defined | ACCT CODE | CHAR | 15
+If format is 'oneline', there is no detail file. Each invoice has a
+header line only, with the fields:
+
+Agent number, agent name, customer number, first name, last name, address
+line 1, address line 2, city, state, zip, invoice date, invoice number,
+amount charged, amount due, previous balance, due date.
+
+and then, for each line item, three columns containing the package number,
+description, and amount.
+
+If format is 'bridgestone', there is no detail file. Each invoice has a
+header line with the following fields in a fixed-width format:
+
+Customer number (in display format), date, name (first last), company,
+address 1, address 2, city, state, zip.
+
+This is a mailing list format, and has no per-invoice fields. To avoid
+sending redundant notices, the spooling event should have a "once" or
+"once_percust_every" condition.
+
=cut
sub print_csv {
my $cust_main = $self->cust_main;
my $csv = Text::CSV_XS->new({'always_quote'=>1});
+ my $format = lc($opt{'format'});
- if ( lc($opt{'format'}) eq 'billco' ) {
+ my $time = $opt{'time'} || time;
+
+ my $tracctnum = ''; #leaking out from billco-specific sections :/
+ if ( $format eq 'billco' ) {
+
+ my $account_num =
+ $self->conf->config('billco-account_num', $cust_main->agentnum);
+
+ $tracctnum = $account_num eq 'display_custnum'
+ ? $cust_main->display_custnum
+ : $opt{'tracctnum'};
my $taxtotal = 0;
$taxtotal += $_->{'amount'} foreach $self->_items_tax;
- my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
+ my $duedate = $self->due_date2str('%m/%d/%Y'); # hardcoded, NOT date_format
my( $previous_balance, @unused ) = $self->previous; #previous balance
my $pmt_cr_applied = 0;
$pmt_cr_applied += $_->{'amount'}
- foreach ( $self->_items_payments, $self->_items_credits ) ;
+ foreach ( $self->_items_payments(%opt), $self->_items_credits(%opt) ) ;
my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
$csv->combine(
'', # 1 | N/A-Leave Empty CHAR 2
'', # 2 | N/A-Leave Empty CHAR 15
- $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
+ $tracctnum, # 3 | Transaction Account No CHAR 15
$self->invnum, # 4 | Transaction Invoice No CHAR 15
$cust_main->zip, # 5 | Transaction Zip Code CHAR 5
$cust_main->company, # 6 | Transaction Company Bill To CHAR 30
'0', # 29 | Other Taxes & Fees*** NUM* 9
);
- } else {
+ } elsif ( $format eq 'oneline' ) { #name
+
+ my ($previous_balance) = $self->previous;
+ $previous_balance = sprintf('%.2f', $previous_balance);
+ my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
+ my @items = map {
+ $_->{pkgnum},
+ $_->{description},
+ $_->{amount}
+ }
+ $self->_items_pkg, #_items_nontax? no sections or anything
+ # with this format
+ $self->_items_tax;
+
+ $csv->combine(
+ $cust_main->agentnum,
+ $cust_main->agent->agent,
+ $self->custnum,
+ $cust_main->first,
+ $cust_main->last,
+ $cust_main->company,
+ $cust_main->address1,
+ $cust_main->address2,
+ $cust_main->city,
+ $cust_main->state,
+ $cust_main->zip,
+
+ # invoice fields
+ time2str("%x", $self->_date),
+ $self->invnum,
+ $self->charged,
+ $totaldue,
+ $previous_balance,
+ $self->due_date2str("%x"),
+
+ @items,
+ );
+
+ } elsif ( $format eq 'bridgestone' ) {
+
+ # bypass the CSV stuff and just return this
+ my $longdate = time2str('%B %d, %Y', $time); #current time, right?
+ my $zip = $cust_main->zip;
+ $zip =~ s/\D//;
+ my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
+ || '';
+ return (
+ sprintf(
+ "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
+ $prefix,
+ $cust_main->display_custnum,
+ $longdate,
+ uc(substr($cust_main->contact_firstlast,0,30)),
+ uc(substr($cust_main->company ,0,30)),
+ uc(substr($cust_main->address1 ,0,30)),
+ uc(substr($cust_main->address2 ,0,30)),
+ uc(substr($cust_main->city ,0,20)),
+ uc($cust_main->state),
+ $zip
+ ),
+ '' #detail
+ );
+
+ } elsif ( $format eq 'ics' ) {
+
+ my $bill = $cust_main->bill_location;
+ my $zip = $bill->zip;
+ my $zip4 = '';
+
+ $zip =~ s/\D//;
+ if ( $zip =~ /^(\d{5})(\d{4})$/ ) {
+ $zip = $1;
+ $zip4 = $2;
+ }
+
+ # minor false laziness with print_generic
+ my ($previous_balance) = $self->previous;
+ my $balance_due = $self->owed + $previous_balance;
+ my $payment_total = sum(0, map { $_->{'amount'} } $self->_items_payments);
+ my $credit_total = sum(0, map { $_->{'amount'} } $self->_items_credits);
+
+ my $past_due = '';
+ if ( $self->due_date and $time >= $self->due_date ) {
+ $past_due = sprintf('Past due:$%0.2f Due Immediately', $balance_due);
+ }
+
+ # again, bypass CSV
+ my $header = sprintf(
+ '%-10s%-30s%-48s%-2s%-50s%-30s%-30s%-25s%-2s%-5s%-4s%-8s%-8s%-10s%-10s%-10s%-10s%-10s%-10s%-480s%-35s',
+ $cust_main->display_custnum, #BID
+ uc($cust_main->first), #FNAME
+ uc($cust_main->last), #LNAME
+ '00', #BATCH, should this ever be anything else?
+ uc($cust_main->company), #COMP
+ uc($bill->address1), #STREET1
+ uc($bill->address2), #STREET2
+ uc($bill->city), #CITY
+ uc($bill->state), #STATE
+ $zip,
+ $zip4,
+ time2str('%Y%m%d', $self->_date), #BILL_DATE
+ $self->due_date2str('%Y%m%d'), #DUE_DATE,
+ ( map {sprintf('%0.2f', $_)}
+ $balance_due, #AMNT_DUE
+ $previous_balance, #PREV_BAL
+ $payment_total, #PYMT_RCVD
+ $credit_total, #CREDITS
+ $previous_balance, #BEG_BAL--is this correct?
+ $self->charged, #NEW_CHRG
+ ),
+ 'img01', #MRKT_MSG?
+ $past_due, #PAST_MSG
+ );
+
+ my @details;
+ my %svc_class = ('' => ''); # maybe cache this more persistently?
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+
+ my $show_pkgnum = $cust_bill_pkg->pkgnum || '';
+ my $cust_pkg = $cust_bill_pkg->cust_pkg if $show_pkgnum;
+
+ if ( $cust_pkg ) {
+
+ my @dates = ( $self->_date, undef );
+ if ( my $prev = $cust_bill_pkg->previous_cust_bill_pkg ) {
+ $dates[1] = $prev->sdate; #questionable
+ }
+
+ # generate an 01 detail for each service
+ my @svcs = $cust_pkg->h_cust_svc(@dates, 'I');
+ foreach my $cust_svc ( @svcs ) {
+ $show_pkgnum = ''; # hide it if we're showing svcnums
+
+ my $svcpart = $cust_svc->svcpart;
+ if (!exists($svc_class{$svcpart})) {
+ my $classnum = $cust_svc->part_svc->classnum;
+ my $part_svc_class = FS::part_svc_class->by_key($classnum)
+ if $classnum;
+ $svc_class{$svcpart} = $part_svc_class ?
+ $part_svc_class->classname :
+ '';
+ }
+
+ my @h_label = $cust_svc->label(@dates, 'I');
+ push @details, sprintf('01%-9s%-20s%-47s',
+ $cust_svc->svcnum,
+ $svc_class{$svcpart},
+ $h_label[1],
+ );
+ } #foreach $cust_svc
+ } #if $cust_pkg
+
+ my $desc = $cust_bill_pkg->desc; # itemdesc or part_pkg.pkg
+ if ($cust_bill_pkg->recur > 0) {
+ $desc .= ' '.time2str('%d-%b-%Y', $cust_bill_pkg->sdate).' to '.
+ time2str('%d-%b-%Y', $cust_bill_pkg->edate - 86400);
+ }
+ push @details, sprintf('02%-6s%-60s%-10s',
+ $show_pkgnum,
+ $desc,
+ sprintf('%0.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
+ );
+ } #foreach $cust_bill_pkg
+
+ # Tag this row so that we know whether this is one page (1), two pages
+ # (2), # or "big" (B). The tag will be stripped off before uploading.
+ if ( scalar(@details) < 12 ) {
+ push @details, '1';
+ } elsif ( scalar(@details) < 58 ) {
+ push @details, '2';
+ } else {
+ push @details, 'B';
+ }
+
+ return join('', $header, @details, "\n");
+
+ } else { # default
$csv->combine(
'cust_bill',
if ( lc($opt{'format'}) eq 'billco' ) {
my $lineseq = 0;
- foreach my $item ( $self->_items_pkg ) {
+ my %items_opt = ( format => 'template',
+ escape_function => sub { shift } );
+ # I don't know what characters billco actually tolerates in spool entries.
+ # Text::CSV will take care of delimiters, though.
+
+ my @items = ( $self->_items_pkg(%items_opt),
+ $self->_items_fee(%items_opt) );
+ foreach my $item (@items) {
+
+ my $description = $item->{'description'};
+ if ( $item->{'_is_discount'} and exists($item->{ext_description}[0]) ) {
+ $description .= ': ' . $item->{ext_description}[0];
+ }
$csv->combine(
'', # 1 | N/A-Leave Empty CHAR 2
'', # 2 | N/A-Leave Empty CHAR 15
- $opt{'tracctnum'}, # 3 | Account Number CHAR 15
+ $tracctnum, # 3 | Account Number CHAR 15
$self->invnum, # 4 | Invoice Number CHAR 15
$lineseq++, # 5 | Line Sequence (sort order) NUM 6
- $item->{'description'}, # 6 | Transaction Detail CHAR 100
+ $description, # 6 | Transaction Detail CHAR 100
$item->{'amount'}, # 7 | Amount NUM* 9
'', # 8 | Line Format Control** CHAR 2
'', # 9 | Grouping Code CHAR 2
}
+ } elsif ( lc($opt{'format'}) eq 'oneline' ) {
+
+ #do nothing
+
} else {
foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
? time2str("%x", $cust_bill_pkg->sdate)
: '' ),
($cust_bill_pkg->edate
- ?time2str("%x", $cust_bill_pkg->edate)
+ ? time2str("%x", $cust_bill_pkg->edate)
: '' ),
);
} else { #pkgnum tax
next unless $cust_bill_pkg->setup != 0;
- my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
- ? ( $cust_bill_pkg->itemdesc || 'Tax' )
- : 'Tax';
- ($pkg, $setup, $recur, $sdate, $edate) =
- ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
+ $pkg = $cust_bill_pkg->desc;
+ $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
+ ( $sdate, $edate ) = ( '', '' );
}
$csv->combine(
}
-=item comp
-
-Pays this invoice with a compliemntary payment. If there is an error,
-returns the error, otherwise returns false.
-
-=cut
-
-sub comp {
- my $self = shift;
- my $cust_pay = new FS::cust_pay ( {
- 'invnum' => $self->invnum,
- 'paid' => $self->owed,
- '_date' => '',
- 'payby' => 'COMP',
- 'payinfo' => $self->cust_main->payinfo,
- 'paybatch' => '',
- } );
- $cust_pay->insert;
-}
+sub comp {
+ croak 'cust_bill->comp is deprecated (COMP payments are deprecated)';
+}
=item realtime_card
}
sub realtime_bop {
- my( $self, $method ) = @_;
+ my( $self, $method ) = (shift,shift);
+ my $conf = $self->conf;
+ my %opt = @_;
my $cust_main = $self->cust_main;
my $balance = $cust_main->balance;
$cust_main->realtime_bop($method, $amount,
'description' => $description,
'invnum' => $self->invnum,
+#this didn't do what we want, it just calls apply_payments_and_credits
+# 'apply' => 1,
+ 'apply_to_invoice' => 1,
+ %opt,
+ #what we want:
+ #this changes application behavior: auto payments
+ #triggered against a specific invoice are now applied
+ #to that invoice instead of oldest open.
+ #seem okay to me...
);
}
$self->cust_main->agent_invoice_from;
}
-=item print_text [ TIME [ , TEMPLATE ] ]
+=item invoice_barcode DIR_OR_FALSE
-Returns an text invoice, as a list of lines.
-
-TIME an optional value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
+Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
+it is taken as the temp directory where the PNG file will be generated and the
+PNG file name is returned. Otherwise, the PNG image itself is returned.
=cut
-sub print_text {
- my( $self, $today, $template ) = @_;
-
- my %params = ( 'format' => 'template' );
- $params{'time'} = $today if $today;
- $params{'template'} = $template if $template;
-
- $self->print_generic( %params );
+sub invoice_barcode {
+ my ($self, $dir) = (shift,shift);
+
+ my $gdbar = new GD::Barcode('Code39',$self->invnum);
+ die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
+ my $gd = $gdbar->plot(Height => 30);
+
+ if($dir) {
+ my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
+ DIR => $dir,
+ SUFFIX => '.png',
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+ print $bh $gd->png or die "cannot write barcode to file: $!\n";
+ my $png_file = $bh->filename;
+ close $bh;
+ return $png_file;
+ }
+ return $gd->png;
}
-=item print_latex [ TIME [ , TEMPLATE ] ]
+=item invnum_date_pretty
-Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename), and a filename of
-an associated logo (with the .eps extension included).
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)".
-See print_ps and print_pdf for methods that return PostScript and PDF output.
-
-TIME an optional value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
+Intended for back-end context, with regard to translation and date formatting.
=cut
-sub print_latex {
- my( $self, $today, $template ) = @_;
-
- my %params = ( 'format' => 'latex' );
- $params{'time'} = $today if $today;
- $params{'template'} = $template if $template;
-
- $template ||= $self->_agent_template;
-
- my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
- my $lh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
- DIR => $dir,
- SUFFIX => '.eps',
- UNLINK => 0,
- ) or die "can't open temp file: $!\n";
-
- my $agentnum = $self->cust_main->agentnum;
-
- if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
- print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
- or die "can't write temp file: $!\n";
- } else {
- print $lh $conf->config_binary('logo.eps', $agentnum)
- or die "can't write temp file: $!\n";
- }
- close $lh;
- $params{'logo_file'} = $lh->filename;
-
- my @filled_in = $self->print_generic( %params );
-
- my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
- DIR => $dir,
- SUFFIX => '.tex',
- UNLINK => 0,
- ) or die "can't open temp file: $!\n";
- print $fh join('', @filled_in );
- close $fh;
-
- $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
- return ($1, $params{'logo_file'});
-
+#note: this uses _date_pretty_unlocalized because _date_pretty is too expensive
+# for backend use (and also does the wrong thing, localizing for end customer
+# instead of backoffice configured date format)
+sub invnum_date_pretty {
+ my $self = shift;
+ #$self->mt('Invoice #').
+ 'Invoice #'. #XXX should be translated ala web UI user (not invoice customer)
+ $self->invnum. ' ('. $self->_date_pretty_unlocalized. ')';
}
-=item print_generic OPTIONS_HASH
-
-Internal method - returns a filled-in template for this invoice as a scalar.
-
-See print_ps and print_pdf for methods that return PostScript and PDF output.
-
-Non optional options include
- format - latex, html, template
-
-Optional options include
-
-template - a value used as a suffix for a configuration template
-
-time - a value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
-
-cid -
-
-unsquelch_cdr - overrides any per customer cdr squelching when true
-
-=cut
-
-#what's with all the sprintf('%10.2f')'s in here? will it cause any
-# (alignment?) problems to change them all to '%.2f' ?
-sub print_generic {
-
- my( $self, %params ) = @_;
- my $today = $params{today} ? $params{today} : time;
- warn "FS::cust_bill::print_generic called on $self with suffix $params{template}\n"
- if $DEBUG;
-
- my $format = $params{format};
- die "Unknown format: $format"
- unless $format =~ /^(latex|html|template)$/;
-
- my $cust_main = $self->cust_main;
- $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
- unless $cust_main->payname
- && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
-
- my %delimiters = ( 'latex' => [ '[@--', '--@]' ],
- 'html' => [ '<%=', '%>' ],
- 'template' => [ '{', '}' ],
- );
-
- #create the template
- my $template = $params{template} ? $params{template} : $self->_agent_template;
- my $templatefile = "invoice_$format";
- $templatefile .= "_$template"
- if length($template);
- my @invoice_template = map "$_\n", $conf->config($templatefile)
- or die "cannot load config data $templatefile";
-
- my $old_latex = '';
- if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
- #change this to a die when the old code is removed
- warn "old-style invoice template $templatefile; ".
- "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
- $old_latex = 'true';
- @invoice_template = _translate_old_latex_format(@invoice_template);
- }
-
- my $text_template = new Text::Template(
- TYPE => 'ARRAY',
- SOURCE => \@invoice_template,
- DELIMITERS => $delimiters{$format},
- );
-
- $text_template->compile()
- or die "Can't compile $templatefile: $Text::Template::ERROR\n";
-
-
- # additional substitution could possibly cause breakage in existing templates
- my %convert_maps = (
- 'latex' => {
- 'notes' => sub { map "$_", @_ },
- 'footer' => sub { map "$_", @_ },
- 'smallfooter' => sub { map "$_", @_ },
- 'returnaddress' => sub { map "$_", @_ },
- 'coupon' => sub { map "$_", @_ },
- },
- 'html' => {
- 'notes' =>
- sub {
- map {
- s/%%(.*)$/<!-- $1 -->/g;
- s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
- s/\\begin\{enumerate\}/<ol>/g;
- s/\\item / <li>/g;
- s/\\end\{enumerate\}/<\/ol>/g;
- s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
- s/\\\\\*/<br>/g;
- s/\\dollar ?/\$/g;
- s/\\#/#/g;
- s/~/ /g;
- $_;
- } @_
- },
- 'footer' =>
- sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
- 'smallfooter' =>
- sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
- 'returnaddress' =>
- sub {
- map {
- s/~/ /g;
- s/\\\\\*?\s*$/<BR>/;
- s/\\hyphenation\{[\w\s\-]+}//;
- s/\\([&])/$1/g;
- $_;
- } @_
- },
- 'coupon' => sub { "" },
- },
- 'template' => {
- 'notes' =>
- sub {
- map {
- s/%%.*$//g;
- s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
- s/\\begin\{enumerate\}//g;
- s/\\item / * /g;
- s/\\end\{enumerate\}//g;
- s/\\textbf\{(.*)\}/$1/g;
- s/\\\\\*/ /;
- s/\\dollar ?/\$/g;
- $_;
- } @_
- },
- 'footer' =>
- sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
- 'smallfooter' =>
- sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
- 'returnaddress' =>
- sub {
- map {
- s/~/ /g;
- s/\\\\\*?\s*$/\n/; # dubious
- s/\\hyphenation\{[\w\s\-]+}//;
- $_;
- } @_
- },
- 'coupon' => sub { "" },
- },
- );
-
-
- # hashes for differing output formats
- my %nbsps = ( 'latex' => '~',
- 'html' => '', # '&nbps;' would be nice
- 'template' => '', # not used
- );
- my $nbsp = $nbsps{$format};
-
- my %escape_functions = ( 'latex' => \&_latex_escape,
- 'html' => \&encode_entities,
- 'template' => sub { shift },
- );
- my $escape_function = $escape_functions{$format};
-
- my %date_formats = ( 'latex' => '%b %o, %Y',
- 'html' => '%b %o, %Y',
- 'template' => '%s',
- );
- my $date_format = $date_formats{$format};
-
- my %embolden_functions = ( 'latex' => sub { return '\textbf{'. shift(). '}'
- },
- 'html' => sub { return '<b>'. shift(). '</b>'
- },
- 'template' => sub { shift },
- );
- my $embolden_function = $embolden_functions{$format};
-
-
- # generate template variables
- my $returnaddress;
- if (
- defined( $conf->config_orbase( "invoice_${format}returnaddress",
- $template
- )
- )
- && length( $conf->config_orbase( "invoice_${format}returnaddress",
- $template
- )
- )
- ) {
-
- $returnaddress = join("\n",
- $conf->config_orbase("invoice_${format}returnaddress", $template)
- );
-
- } elsif ( grep /\S/,
- $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
-
- my $convert_map = $convert_maps{$format}{'returnaddress'};
- $returnaddress =
- join( "\n",
- &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
- $template
- )
- )
- );
- } elsif ( grep /\S/, $conf->config('company_address', $self->cust_main->agentnum) ) {
-
- my $convert_map = $convert_maps{$format}{'returnaddress'};
- $returnaddress = join( "\n", &$convert_map(
- map { s/( {2,})/'~' x length($1)/eg;
- s/$/\\\\\*/;
- $_
- }
- ( $conf->config('company_name', $self->cust_main->agentnum),
- $conf->config('company_address', $self->cust_main->agentnum),
- )
- )
- );
-
- } else {
-
- my $warning = "Couldn't find a return address; ".
- "do you need to set the company_address configuration value?";
- warn "$warning\n";
- $returnaddress = $nbsp;
- #$returnaddress = $warning;
+#sub _items_extra_usage_sections {
+# my $self = shift;
+# my $escape = shift;
+#
+# my %sections = ();
+#
+# my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
+# foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+# {
+# next unless $cust_bill_pkg->pkgnum > 0;
+#
+# foreach my $section ( keys %usage_class ) {
+#
+# my $usage = $cust_bill_pkg->usage($section);
+#
+# next unless $usage && $usage > 0;
+#
+# $sections{$section} ||= 0;
+# $sections{$section} += $usage;
+#
+# }
+#
+# }
+#
+# map { { 'description' => &{$escape}($_),
+# 'subtotal' => $sections{$_},
+# 'summarized' => '',
+# 'tax_section' => '',
+# }
+# }
+# sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
+#
+#}
- }
+sub _items_extra_usage_sections {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $escape = shift;
+ my $format = shift;
+
+ my %sections = ();
+ my %classnums = ();
+ my %lines = ();
+
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
+
+ my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+
+ foreach my $classnum ( keys %usage_class ) {
+ my $section = $usage_class{$classnum}->classname;
+ $classnums{$section} = $classnum;
+
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
+ $sections{$section}{amount} += $amount; #subtotal
+ $sections{$section}{calls}++;
+ $sections{$section}{duration} += $detail->duration;
+
+ my $desc = $detail->regionname;
+ my $description = $desc;
+ $description = substr($desc, 0, $maxlength). '...'
+ if $format eq 'latex' && length($desc) > $maxlength;
+
+ $lines{$section}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => $cust_bill_pkg->pkgnum,
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => $cust_bill_pkg->unitrecur,
+ quantity => $cust_bill_pkg->quantity,
+ product_code => 'N/A',
+ ext_description => [],
+ };
- my %invoice_data = (
- 'company_name' => scalar( $conf->config('company_name', $self->cust_main->agentnum) ),
- 'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n",
- 'custnum' => $cust_main->display_custnum,
- 'invnum' => $self->invnum,
- 'date' => time2str($date_format, $self->_date),
- 'today' => time2str('%b %o, %Y', $today),
- 'agent' => &$escape_function($cust_main->agent->agent),
- 'agent_custid' => &$escape_function($cust_main->agent_custid),
- 'payname' => &$escape_function($cust_main->payname),
- 'company' => &$escape_function($cust_main->company),
- 'address1' => &$escape_function($cust_main->address1),
- 'address2' => &$escape_function($cust_main->address2),
- 'city' => &$escape_function($cust_main->city),
- 'state' => &$escape_function($cust_main->state),
- 'zip' => &$escape_function($cust_main->zip),
- 'fax' => &$escape_function($cust_main->fax),
- 'returnaddress' => $returnaddress,
- #'quantity' => 1,
- 'terms' => $self->terms,
- 'template' => $template, #params{'template'},
- #'notes' => join("\n", $conf->config('invoice_latexnotes') ),
- # better hang on to conf_dir for a while
- 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
- 'page' => 1,
- 'total_pages' => 1,
- 'current_charges' => sprintf("%.2f", $self->charged),
- 'duedate' => $self->due_date2str('%m/%d/%Y'), #date_format?
- 'ship_enable' => $conf->exists('invoice-ship_address'),
- 'unitprices' => $conf->exists('invoice-unitprice'),
- );
+ $lines{$section}{$desc}{amount} += $amount;
+ $lines{$section}{$desc}{calls}++;
+ $lines{$section}{$desc}{duration} += $detail->duration;
- my $countrydefault = $conf->config('countrydefault') || 'US';
- my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
- foreach ( qw( contact company address1 address2 city state zip country fax) ){
- my $method = $prefix.$_;
- $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+ }
+ }
}
- $invoice_data{'ship_country'} = ''
- if ( $invoice_data{'ship_country'} eq $countrydefault );
-
- $invoice_data{'cid'} = $params{'cid'}
- if $params{'cid'};
- if ( $cust_main->country eq $countrydefault ) {
- $invoice_data{'country'} = '';
- } else {
- $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+ my %sectionmap = ();
+ foreach (keys %sections) {
+ my $usage_class = $usage_class{$classnums{$_}};
+ $sectionmap{$_} = { 'description' => &{$escape}($_),
+ 'amount' => $sections{$_}{amount}, #subtotal
+ 'calls' => $sections{$_}{calls},
+ 'duration' => $sections{$_}{duration},
+ 'summarized' => '',
+ 'tax_section' => '',
+ 'sort_weight' => $usage_class->weight,
+ ( $usage_class->format
+ ? ( map { $_ => $usage_class->$_($format) }
+ qw( description_generator header_generator total_generator total_line_generator )
+ )
+ : ()
+ ),
+ };
}
- my @address = ();
- $invoice_data{'address'} = \@address;
- push @address,
- $cust_main->payname.
- ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
- ? " (P.O. #". $cust_main->payinfo. ")"
- : ''
- )
- ;
- push @address, $cust_main->company
- if $cust_main->company;
- push @address, $cust_main->address1;
- push @address, $cust_main->address2
- if $cust_main->address2;
- push @address,
- $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip;
- push @address, $invoice_data{'country'}
- if $invoice_data{'country'};
- push @address, ''
- while (scalar(@address) < 5);
-
- $invoice_data{'logo_file'} = $params{'logo_file'}
- if $params{'logo_file'};
-
- my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
- #my $balance_due = $self->owed + $pr_total - $cr_total;
- my $balance_due = $self->owed + $pr_total;
- $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
- $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
-
- my $agentnum = $self->cust_main->agentnum;
-
- #do variable substitution in notes, footer, smallfooter
- foreach my $include (qw( notes footer smallfooter coupon )) {
-
- my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
- my @inc_src;
-
- if ( $conf->exists($inc_file, $agentnum)
- && length( $conf->config($inc_file, $agentnum) ) ) {
-
- @inc_src = $conf->config($inc_file, $agentnum);
-
- } else {
-
- $inc_file = $conf->key_orbase("invoice_latex$include", $template);
-
- my $convert_map = $convert_maps{$format}{$include};
-
- @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
- s/--\@\]/$delimiters{$format}[1]/g;
- $_;
- }
- &$convert_map( $conf->config($inc_file, $agentnum) );
-
- }
-
- my $inc_tt = new Text::Template (
- TYPE => 'ARRAY',
- SOURCE => [ map "$_\n", @inc_src ],
- DELIMITERS => $delimiters{$format},
- ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
-
- unless ( $inc_tt->compile() ) {
- my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
- warn $error. "Template:\n". join('', map "$_\n", @inc_src);
- die $error;
+ my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
+ values %sectionmap;
+
+ my @lines = ();
+ foreach my $section ( keys %lines ) {
+ foreach my $line ( keys %{$lines{$section}} ) {
+ my $l = $lines{$section}{$line};
+ $l->{section} = $sectionmap{$section};
+ $l->{amount} = sprintf( "%.2f", $l->{amount} );
+ #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+ push @lines, $l;
}
-
- $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
-
- $invoice_data{$include} =~ s/\n+$//
- if ($format eq 'latex');
}
- $invoice_data{'po_line'} =
- ( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
- ? &$escape_function("Purchase Order #". $cust_main->payinfo)
- : $nbsp;
-
- my %money_chars = ( 'latex' => '',
- 'html' => $conf->config('money_char') || '$',
- 'template' => '',
- );
- my $money_char = $money_chars{$format};
-
- my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
- 'html' => $conf->config('money_char') || '$',
- 'template' => '',
- );
- my $other_money_char = $other_money_chars{$format};
-
- my @detail_items = ();
- my @total_items = ();
- my @buf = ();
- my @sections = ();
-
- $invoice_data{'detail_items'} = \@detail_items;
- $invoice_data{'total_items'} = \@total_items;
- $invoice_data{'buf'} = \@buf;
- $invoice_data{'sections'} = \@sections;
-
- my $previous_section = { 'description' => 'Previous Charges',
- 'subtotal' => $other_money_char.
- sprintf('%.2f', $pr_total),
- };
-
- my $taxtotal = 0;
- my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees',
- 'subtotal' => $taxtotal }; # adjusted below
-
- my $adjusttotal = 0;
- my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments',
- 'subtotal' => 0 }; # adjusted below
-
- my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
- my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
- my $late_sections = [];
- if ( $multisection ) {
- push @sections, $self->_items_sections( $late_sections );
- }else{
- push @sections, { 'description' => '', 'subtotal' => '' };
- }
-
- unless ( $conf->exists('disable_previous_balance')
- || $conf->exists('previous_balance-summary_only')
- )
- {
-
- foreach my $line_item ( $self->_items_previous ) {
+ return(\@sections, \@lines);
+
+}
+
+sub _did_summary {
+ my $self = shift;
+ my $end = $self->_date;
+
+ # start at date of previous invoice + 1 second or 0 if no previous invoice
+ my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
+ $start = 0 if !$start;
+ $start++;
+
+ my $cust_main = $self->cust_main;
+ my @pkgs = $cust_main->all_pkgs;
+ my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
+ = (0,0,0,0,0);
+ my @seen = ();
+ foreach my $pkg ( @pkgs ) {
+ my @h_cust_svc = $pkg->h_cust_svc($end);
+ foreach my $h_cust_svc ( @h_cust_svc ) {
+ next if grep {$_ eq $h_cust_svc->svcnum} @seen;
+ next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
+
+ my $inserted = $h_cust_svc->date_inserted;
+ my $deleted = $h_cust_svc->date_deleted;
+ my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
+ my $phone_deleted;
+ $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted;
+
+# DID either activated or ported in; cannot be both for same DID simultaneously
+ if ($inserted >= $start && $inserted <= $end && $phone_inserted
+ && (!$phone_inserted->lnp_status
+ || $phone_inserted->lnp_status eq ''
+ || $phone_inserted->lnp_status eq 'native')) {
+ $num_activated++;
+ }
+ else { # this one not so clean, should probably move to (h_)svc_phone
+ local($FS::Record::qsearch_qualify_columns) = 0;
+ my $phone_portedin = qsearchs( 'h_svc_phone',
+ { 'svcnum' => $h_cust_svc->svcnum,
+ 'lnp_status' => 'portedin' },
+ FS::h_svc_phone->sql_h_searchs($end),
+ );
+ $num_portedin++ if $phone_portedin;
+ }
+
+# DID either deactivated or ported out; cannot be both for same DID simultaneously
+ if($deleted >= $start && $deleted <= $end && $phone_deleted
+ && (!$phone_deleted->lnp_status
+ || $phone_deleted->lnp_status ne 'portingout')) {
+ $num_deactivated++;
+ }
+ elsif($deleted >= $start && $deleted <= $end && $phone_deleted
+ && $phone_deleted->lnp_status
+ && $phone_deleted->lnp_status eq 'portingout') {
+ $num_portedout++;
+ }
+
+ # increment usage minutes
+ if ( $phone_inserted ) {
+ my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
+ $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
+ }
+ else {
+ warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
+ }
- my $detail = {
- ext_description => [],
- };
- $detail->{'ref'} = $line_item->{'pkgnum'};
- $detail->{'quantity'} = 1;
- $detail->{'section'} = $previous_section;
- $detail->{'description'} = &$escape_function($line_item->{'description'});
- if ( exists $line_item->{'ext_description'} ) {
- @{$detail->{'ext_description'}} = map {
- &$escape_function($_);
- } @{$line_item->{'ext_description'}};
- }
- $detail->{'amount'} = ( $old_latex ? '' : $money_char).
- $line_item->{'amount'};
- $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
- push @detail_items, $detail;
- push @buf, [ $detail->{'description'},
- $money_char. sprintf("%10.2f", $line_item->{'amount'}),
- ];
+ # don't look at this service again
+ push @seen, $h_cust_svc->svcnum;
+ }
}
- }
+ $minutes = sprintf("%d", $minutes);
+ ("Activated: $num_activated Ported-In: $num_portedin Deactivated: "
+ . "$num_deactivated Ported-Out: $num_portedout ",
+ "Total Minutes: $minutes");
+}
+
+sub _items_accountcode_cdr {
+ my $self = shift;
+ my $escape = shift;
+ my $format = shift;
+
+ my $section = { 'amount' => 0,
+ 'calls' => 0,
+ 'duration' => 0,
+ 'sort_weight' => '',
+ 'phonenum' => '',
+ 'description' => 'Usage by Account Code',
+ 'post_total' => '',
+ 'summarized' => '',
+ 'header' => '',
+ };
+ my @lines;
+ my %accountcodes = ();
- if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
- push @buf, ['','-----------'];
- push @buf, [ 'Total Previous Balance',
- $money_char. sprintf("%10.2f", $pr_total) ];
- push @buf, ['',''];
- }
-
- foreach my $section (@sections, @$late_sections) {
-
- $section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $section->{'subtotal'})
- if $multisection;
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
- if ( $section->{'description'} ) {
- push @buf, ( [ &$escape_function($section->{'description'}), '' ],
- [ '', '' ],
- );
- }
+ my @header = $cust_bill_pkg->details_header;
+ next unless scalar(@header);
+ $section->{'header'} = join(',',@header);
- my %options = ();
- $options{'section'} = $section if $multisection;
- $options{'format'} = $format;
- $options{'escape_function'} = $escape_function;
- $options{'format_function'} = sub { () } unless $unsquelched;
- $options{'unsquelched'} = $unsquelched;
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
- foreach my $line_item ( $self->_items_pkg(%options) ) {
- my $detail = {
- ext_description => [],
- };
- $detail->{'ref'} = $line_item->{'pkgnum'};
- $detail->{'quantity'} = $line_item->{'quantity'};
- $detail->{'section'} = $section;
- $detail->{'description'} = &$escape_function($line_item->{'description'});
- if ( exists $line_item->{'ext_description'} ) {
- @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
- }
- $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
- $line_item->{'amount'};
- $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
- $line_item->{'unit_amount'};
- $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
- push @detail_items, $detail;
- push @buf, ( [ $detail->{'description'},
- $money_char. sprintf("%10.2f", $line_item->{'amount'}),
- ],
- map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
- );
+ $section->{'header'} = $detail->formatted('format' => $format)
+ if($detail->detail eq $section->{'header'});
+
+ my $accountcode = $detail->accountcode;
+ next unless $accountcode;
+
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $accountcodes{$accountcode} ||= {
+ description => $accountcode,
+ pkgnum => '',
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ quantity => '',
+ product_code => 'N/A',
+ section => $section,
+ ext_description => [ $section->{'header'} ],
+ detail_temp => [],
+ };
+
+ $section->{'amount'} += $amount;
+ $accountcodes{$accountcode}{'amount'} += $amount;
+ $accountcodes{$accountcode}{calls}++;
+ $accountcodes{$accountcode}{duration} += $detail->duration;
+ push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
+ }
}
- if ( $section->{'description'} ) {
- push @buf, ( ['','-----------'],
- [ $section->{'description'}. ' sub-total',
- $money_char. sprintf("%10.2f", $section->{'subtotal'})
- ],
- [ '', '' ],
- [ '', '' ],
- );
+ foreach my $l ( values %accountcodes ) {
+ $l->{amount} = sprintf( "%.2f", $l->{amount} );
+ my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
+ foreach my $sorted_detail ( @sorted_detail ) {
+ push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
+ }
+ delete $l->{detail_temp};
+ push @lines, $l;
}
-
- }
-
- if ( $multisection && !$conf->exists('disable_previous_balance') ) {
- unshift @sections, $previous_section if $pr_total;
- }
-
- foreach my $tax ( $self->_items_tax ) {
-
- $taxtotal += $tax->{'amount'};
- my $description = &$escape_function( $tax->{'description'} );
- my $amount = sprintf( '%.2f', $tax->{'amount'} );
+ my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
- if ( $multisection ) {
+ return ($section,\@sorted_lines);
+}
- my $money = $old_latex ? '' : $money_char;
- push @detail_items, {
+sub _items_svc_phone_sections {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $escape = shift;
+ my $format = shift;
+
+ my %sections = ();
+ my %classnums = ();
+ my %lines = ();
+
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
+
+ my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+ $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+
+ my @header = $cust_bill_pkg->details_header;
+ next unless scalar(@header);
+
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
+
+ my $phonenum = $detail->phonenum;
+ next unless $phonenum;
+
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $sections{$phonenum} ||= { 'amount' => 0,
+ 'calls' => 0,
+ 'duration' => 0,
+ 'sort_weight' => -1,
+ 'phonenum' => $phonenum,
+ };
+ $sections{$phonenum}{amount} += $amount; #subtotal
+ $sections{$phonenum}{calls}++;
+ $sections{$phonenum}{duration} += $detail->duration;
+
+ my $desc = $detail->regionname;
+ my $description = $desc;
+ $description = substr($desc, 0, $maxlength). '...'
+ if $format eq 'latex' && length($desc) > $maxlength;
+
+ $lines{$phonenum}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => '',
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => '',
+ quantity => '',
+ product_code => 'N/A',
ext_description => [],
- ref => '',
- quantity => '',
- description => $description,
- amount => $money. $amount,
- product_code => '',
- section => $tax_section,
};
- } else {
-
- push @total_items, {
- 'total_item' => $description,
- 'total_amount' => $other_money_char. $amount,
+ $lines{$phonenum}{$desc}{amount} += $amount;
+ $lines{$phonenum}{$desc}{calls}++;
+ $lines{$phonenum}{$desc}{duration} += $detail->duration;
+
+ my $line = $usage_class{$detail->classnum}->classname;
+ $sections{"$phonenum $line"} ||=
+ { 'amount' => 0,
+ 'calls' => 0,
+ 'duration' => 0,
+ 'sort_weight' => $usage_class{$detail->classnum}->weight,
+ 'phonenum' => $phonenum,
+ 'header' => [ @header ],
+ };
+ $sections{"$phonenum $line"}{amount} += $amount; #subtotal
+ $sections{"$phonenum $line"}{calls}++;
+ $sections{"$phonenum $line"}{duration} += $detail->duration;
+
+ $lines{"$phonenum $line"}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => '',
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => '',
+ quantity => '',
+ product_code => 'N/A',
+ ext_description => [],
};
- }
-
- push @buf,[ $description,
- $money_char. $amount,
- ];
+ $lines{"$phonenum $line"}{$desc}{amount} += $amount;
+ $lines{"$phonenum $line"}{$desc}{calls}++;
+ $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
+ push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
+ $detail->formatted('format' => $format);
- }
-
- if ( $taxtotal ) {
- my $total = {};
- $total->{'total_item'} = 'Sub-total';
- $total->{'total_amount'} =
- $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
-
- if ( $multisection ) {
- $tax_section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $taxtotal);
- $tax_section->{'pretotal'} = 'New charges sub-total '.
- $total->{'total_amount'};
- push @sections, $tax_section if $taxtotal;
- }else{
- unshift @total_items, $total;
- }
- }
- $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
-
- push @buf,['','-----------'];
- push @buf,[( $conf->exists('disable_previous_balance')
- ? 'Total Charges'
- : 'Total New Charges'
- ),
- $money_char. sprintf("%10.2f",$self->charged) ];
- push @buf,['',''];
-
- {
- my $total = {};
- $total->{'total_item'} = &$embolden_function('Total');
- $total->{'total_amount'} =
- &$embolden_function(
- $other_money_char.
- sprintf( '%.2f',
- $self->charged + ( $conf->exists('disable_previous_balance')
- ? 0
- : $pr_total
- )
- )
- );
- if ( $multisection ) {
- $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
- sprintf('%.2f', $self->charged );
- }else{
- push @total_items, $total;
}
- push @buf,['','-----------'];
- push @buf,['Total Charges',
- $money_char.
- sprintf( '%10.2f', $self->charged +
- ( $conf->exists('disable_previous_balance')
- ? 0
- : $pr_total
- )
- )
- ];
- push @buf,['',''];
}
-
- unless ( $conf->exists('disable_previous_balance') ) {
- #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-
- # credits
- my $credittotal = 0;
- foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
-
- my $total;
- $total->{'total_item'} = &$escape_function($credit->{'description'});
- $credittotal += $credit->{'amount'};
- $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'};
- $adjusttotal += $credit->{'amount'};
- if ( $multisection ) {
- my $money = $old_latex ? '' : $money_char;
- push @detail_items, {
- ext_description => [],
- ref => '',
- quantity => '',
- description => &$escape_function($credit->{'description'}),
- amount => $money. $credit->{'amount'},
- product_code => '',
- section => $adjust_section,
- };
- } else {
- push @total_items, $total;
- }
+ my %sectionmap = ();
+ my $simple = new FS::usage_class { format => 'simple' }; #bleh
+ foreach ( keys %sections ) {
+ my @header = @{ $sections{$_}{header} || [] };
+ my $usage_simple =
+ new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
+ my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
+ my $usage_class = $summary ? $simple : $usage_simple;
+ my $ending = $summary ? ' usage charges' : '';
+ my %gen_opt = ();
+ unless ($summary) {
+ $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
}
- $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
-
- #credits (again)
- foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
- push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
- }
-
- # payments
- my $paymenttotal = 0;
- foreach my $payment ( $self->_items_payments ) {
- my $total = {};
- $total->{'total_item'} = &$escape_function($payment->{'description'});
- $paymenttotal += $payment->{'amount'};
- $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
- $adjusttotal += $payment->{'amount'};
- if ( $multisection ) {
- my $money = $old_latex ? '' : $money_char;
- push @detail_items, {
- ext_description => [],
- ref => '',
- quantity => '',
- description => &$escape_function($payment->{'description'}),
- amount => $money. $payment->{'amount'},
- product_code => '',
- section => $adjust_section,
- };
- }else{
- push @total_items, $total;
- }
- push @buf, [ $payment->{'description'},
- $money_char. sprintf("%10.2f", $payment->{'amount'}),
- ];
- }
- $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-
- if ( $multisection ) {
- $adjust_section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $adjusttotal);
- push @sections, $adjust_section;
- }
-
- {
- my $total;
- $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
- $total->{'total_amount'} =
- &$embolden_function(
- $other_money_char. sprintf('%.2f', $self->owed + $pr_total )
- );
- if ( $multisection ) {
- $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
- $total->{'total_amount'};
- }else{
- push @total_items, $total;
- }
- push @buf,['','-----------'];
- push @buf,[$self->balance_due_msg, $money_char.
- sprintf("%10.2f", $balance_due ) ];
- }
+ $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
+ 'amount' => $sections{$_}{amount}, #subtotal
+ 'calls' => $sections{$_}{calls},
+ 'duration' => $sections{$_}{duration},
+ 'summarized' => '',
+ 'tax_section' => '',
+ 'phonenum' => $sections{$_}{phonenum},
+ 'sort_weight' => $sections{$_}{sort_weight},
+ 'post_total' => $summary, #inspire pagebreak
+ (
+ ( map { $_ => $usage_class->$_($format, %gen_opt) }
+ qw( description_generator
+ header_generator
+ total_generator
+ total_line_generator
+ )
+ )
+ ),
+ };
}
- if ( $multisection ) {
- push @sections, @$late_sections
- if $unsquelched;
- }
-
- $invoice_lines = 0;
- my $wasfunc = 0;
- foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
- /invoice_lines\((\d*)\)/;
- $invoice_lines += $1 || scalar(@buf);
- $wasfunc=1;
- }
- die "no invoice_lines() functions in template?"
- if ( $format eq 'template' && !$wasfunc );
-
- if ($format eq 'template') {
-
- if ( $invoice_lines ) {
- $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
- $invoice_data{'total_pages'}++
- if scalar(@buf) % $invoice_lines;
- }
-
- #setup subroutine for the template
- sub FS::cust_bill::_template::invoice_lines {
- my $lines = shift || scalar(@FS::cust_bill::_template::buf);
- map {
- scalar(@FS::cust_bill::_template::buf)
- ? shift @FS::cust_bill::_template::buf
- : [ '', '' ];
- }
- ( 1 .. $lines );
- }
-
- my $lines;
- my @collect;
- while (@buf) {
- push @collect, split("\n",
- $text_template->fill_in( HASH => \%invoice_data,
- PACKAGE => 'FS::cust_bill::_template'
- )
- );
- $FS::cust_bill::_template::page++;
+ my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
+ $a->{sort_weight} <=> $b->{sort_weight}
+ }
+ values %sectionmap;
+
+ my @lines = ();
+ foreach my $section ( keys %lines ) {
+ foreach my $line ( keys %{$lines{$section}} ) {
+ my $l = $lines{$section}{$line};
+ $l->{section} = $sectionmap{$section};
+ $l->{amount} = sprintf( "%.2f", $l->{amount} );
+ #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+ push @lines, $l;
}
- map "$_\n", @collect;
- }else{
- warn "filling in template for invoice ". $self->invnum. "\n"
- if $DEBUG;
- warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
- if $DEBUG > 1;
-
- $text_template->fill_in(HASH => \%invoice_data);
- }
-}
-
-=item print_ps [ TIME [ , TEMPLATE ] ]
-
-Returns an postscript invoice, as a scalar.
-
-TIME an optional value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=cut
-
-sub print_ps {
- my $self = shift;
-
- my ($file, $lfile) = $self->print_latex(@_);
- my $ps = generate_ps($file);
- unlink($lfile);
-
- $ps;
-}
-
-=item print_pdf [ TIME [ , TEMPLATE ] ]
-
-Returns an PDF invoice, as a scalar.
-
-TIME an optional value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=cut
-
-sub print_pdf {
- my $self = shift;
-
- my ($file, $lfile) = $self->print_latex(@_);
- my $pdf = generate_pdf($file);
- unlink($lfile);
-
- $pdf;
-}
-
-=item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
-
-Returns an HTML invoice, as a scalar.
-
-TIME an optional value used to control the printing of overdue messages. The
-default is now. It isn't the date of the invoice; that's the `_date' field.
-It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
-
-CID is a MIME Content-ID used to create a "cid:" URL for the logo image, used
-when emailing the invoice as part of a multipart/related MIME email.
-
-=cut
-
-sub print_html {
- my $self = shift;
- my %params;
- if ( ref $_[0] ) {
- %params = %{ shift() };
- }else{
- $params{'time'} = shift;
- $params{'template'} = shift;
- $params{'cid'} = shift;
}
-
- $params{'format'} = 'html';
-
- $self->print_generic( %params );
-}
-
-# quick subroutine for print_latex
-#
-# There are ten characters that LaTeX treats as special characters, which
-# means that they do not simply typeset themselves:
-# # $ % & ~ _ ^ \ { }
-#
-# TeX ignores blanks following an escaped character; if you want a blank (as
-# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
-
-sub _latex_escape {
- my $value = shift;
- $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
- $value =~ s/([<>])/\$$1\$/g;
- $value;
-}
-
-#utility methods for print_*
-
-sub _translate_old_latex_format {
- warn "_translate_old_latex_format called\n"
- if $DEBUG;
-
- my @template = ();
- while ( @_ ) {
- my $line = shift;
-
- if ( $line =~ /^%%Detail\s*$/ ) {
- push @template, q![@--!,
- q! foreach my $_tr_line (@detail_items) {!,
- q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
- q! $_tr_line->{'description'} .= !,
- q! "\\tabularnewline\n~~".!,
- q! join( "\\tabularnewline\n~~",!,
- q! @{$_tr_line->{'ext_description'}}!,
- q! );!,
- q! }!;
-
- while ( ( my $line_item_line = shift )
- !~ /^%%EndDetail\s*$/ ) {
- $line_item_line =~ s/'/\\'/g; # nice LTS
- $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
- $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
- push @template, " \$OUT .= '$line_item_line';";
+ if($conf->exists('phone_usage_class_summary')) {
+ # this only works with Latex
+ my @newlines;
+ my @newsections;
+
+ # after this, we'll have only two sections per DID:
+ # Calls Summary and Calls Detail
+ foreach my $section ( @sections ) {
+ if($section->{'post_total'}) {
+ $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
+ $section->{'total_line_generator'} = sub { '' };
+ $section->{'total_generator'} = sub { '' };
+ $section->{'header_generator'} = sub { '' };
+ $section->{'description_generator'} = '';
+ push @newsections, $section;
+ my %calls_detail = %$section;
+ $calls_detail{'post_total'} = '';
+ $calls_detail{'sort_weight'} = '';
+ $calls_detail{'description_generator'} = sub { '' };
+ $calls_detail{'header_generator'} = sub {
+ return ' & Date/Time & Called Number & Duration & Price'
+ if $format eq 'latex';
+ '';
+ };
+ $calls_detail{'description'} = 'Calls Detail: '
+ . $section->{'phonenum'};
+ push @newsections, \%calls_detail;
+ }
}
-
- push @template, '}',
- '--@]';
-
- } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
- push @template, '[@--',
- ' foreach my $_tr_line (@total_items) {';
-
- while ( ( my $total_item_line = shift )
- !~ /^%%EndTotalDetails\s*$/ ) {
- $total_item_line =~ s/'/\\'/g; # nice LTS
- $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
- $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
- push @template, " \$OUT .= '$total_item_line';";
+ # after this, each usage class is collapsed/summarized into a single
+ # line under the Calls Summary section
+ foreach my $newsection ( @newsections ) {
+ if($newsection->{'post_total'}) { # this means Calls Summary
+ foreach my $section ( @sections ) {
+ next unless ($section->{'phonenum'} eq $newsection->{'phonenum'}
+ && !$section->{'post_total'});
+ my $newdesc = $section->{'description'};
+ my $tn = $section->{'phonenum'};
+ $newdesc =~ s/$tn//g;
+ my $line = { ext_description => [],
+ pkgnum => '',
+ ref => '',
+ quantity => '',
+ calls => $section->{'calls'},
+ section => $newsection,
+ duration => $section->{'duration'},
+ description => $newdesc,
+ amount => sprintf("%.2f",$section->{'amount'}),
+ product_code => 'N/A',
+ };
+ push @newlines, $line;
+ }
+ }
}
- push @template, '}',
- '--@]';
-
- } else {
- $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
- push @template, $line;
- }
-
- }
+ # after this, Calls Details is populated with all CDRs
+ foreach my $newsection ( @newsections ) {
+ if(!$newsection->{'post_total'}) { # this means Calls Details
+ foreach my $line ( @lines ) {
+ next unless (scalar(@{$line->{'ext_description'}}) &&
+ $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
+ );
+ my @extdesc = @{$line->{'ext_description'}};
+ my @newextdesc;
+ foreach my $extdesc ( @extdesc ) {
+ $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
+ push @newextdesc, $extdesc;
+ }
+ $line->{'ext_description'} = \@newextdesc;
+ $line->{'section'} = $newsection;
+ push @newlines, $line;
+ }
+ }
+ }
- if ($DEBUG) {
- warn "$_\n" foreach @template;
+ return(\@newsections, \@newlines);
}
- (@template);
-}
-
-sub terms {
- my $self = shift;
-
- #check for an invoice- specific override (eventually)
-
- #check for a customer- specific override
- return $self->cust_main->invoice_terms
- if $self->cust_main->invoice_terms;
+ return(\@sections, \@lines);
- #use configured default or default default
- $conf->config('invoice_default_terms') || 'Payable upon receipt';
}
-sub due_date {
- my $self = shift;
- my $duedate = '';
- if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
- $duedate = $self->_date() + ( $1 * 86400 );
- }
- $duedate;
-}
-
-sub due_date2str {
- my $self = shift;
- $self->due_date ? time2str(shift, $self->due_date) : '';
-}
+=sub _items_usage_class_summary OPTIONS
-sub balance_due_msg {
- my $self = shift;
- my $msg = 'Balance Due';
- return $msg unless $self->terms;
- if ( $self->due_date ) {
- $msg .= ' - Please pay by '. $self->due_date2str('%x');
- } elsif ( $self->terms ) {
- $msg .= ' - '. $self->terms;
- }
- $msg;
-}
+Returns a list of detail items summarizing the usage charges on this
+invoice. Each one will have 'amount', 'description' (the usage charge name),
+and 'usage_classnum'.
-sub balance_due_date {
- my $self = shift;
- my $duedate = '';
- if ( $conf->exists('invoice_default_terms')
- && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
- $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
- }
- $duedate;
-}
-
-=item invnum_date_pretty
-
-Returns a string with the invoice number and date, for example:
-"Invoice #54 (3/20/2008)"
-
-=cut
-
-sub invnum_date_pretty {
- my $self = shift;
- 'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
-}
-
-=item _date_pretty
-
-Returns a string with the date, for example: "3/20/2008"
+OPTIONS can include 'escape' (a function to escape the descriptions).
=cut
-sub _date_pretty {
+sub _items_usage_class_summary {
my $self = shift;
- time2str('%x', $self->_date);
-}
-
-sub _items_sections {
- my $self = shift;
- my $late = shift;
-
- my %s = ();
- my %l = ();
-
- foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
- {
-
- if ( $cust_bill_pkg->pkgnum > 0 ) {
- my $usage = $cust_bill_pkg->usage;
-
- foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
- my $desc = $display->section;
- my $type = $display->type;
-
- if ( $display->post_total ) {
- if (! $type || $type eq 'S') {
- $l{$desc} += $cust_bill_pkg->setup
- if ( $cust_bill_pkg->setup != 0 );
- }
-
- if (! $type) {
- $l{$desc} += $cust_bill_pkg->recur
- if ( $cust_bill_pkg->recur != 0 );
- }
-
- if ($type && $type eq 'R') {
- $l{$desc} += $cust_bill_pkg->recur - $usage
- if ( $cust_bill_pkg->recur != 0 );
- }
-
- if ($type && $type eq 'U') {
- $l{$desc} += $usage;
- }
-
- } else {
- if (! $type || $type eq 'S') {
- $s{$desc} += $cust_bill_pkg->setup
- if ( $cust_bill_pkg->setup != 0 );
- }
-
- if (! $type) {
- $s{$desc} += $cust_bill_pkg->recur
- if ( $cust_bill_pkg->recur != 0 );
- }
-
- if ($type && $type eq 'R') {
- $s{$desc} += $cust_bill_pkg->recur - $usage
- if ( $cust_bill_pkg->recur != 0 );
- }
-
- if ($type && $type eq 'U') {
- $s{$desc} += $usage;
- }
-
- }
-
- }
-
- }
-
- }
-
- push @$late, map { { 'description' => $_,
- 'subtotal' => $l{$_},
- 'post_total' => 1,
- } } sort keys %l;
-
- map { {'description' => $_, 'subtotal' => $s{$_}} } sort keys %s;
-
-}
-
-sub _items {
- my $self = shift;
-
- #my @display = scalar(@_)
- # ? @_
- # : qw( _items_previous _items_pkg );
- # #: qw( _items_pkg );
- # #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
- my @display = qw( _items_previous _items_pkg );
+ my %opt = @_;
- my @b = ();
- foreach my $display ( @display ) {
- push @b, $self->$display(@_);
+ my $escape = $opt{escape} || sub { $_[0] };
+ my $money_char = $opt{money_char};
+ my $invnum = $self->invnum;
+ my @classes = qsearch({
+ 'table' => 'usage_class',
+ 'select' => 'classnum, classname, SUM(amount) AS amount,'.
+ ' COUNT(*) AS calls, SUM(duration) AS duration',
+ 'addl_from' => ' LEFT JOIN cust_bill_pkg_detail USING (classnum)' .
+ ' LEFT JOIN cust_bill_pkg USING (billpkgnum)',
+ 'extra_sql' => " WHERE cust_bill_pkg.invnum = $invnum".
+ ' GROUP BY classnum, classname, weight'.
+ ' HAVING (usage_class.disabled IS NULL OR SUM(amount) > 0)'.
+ ' ORDER BY weight ASC',
+ });
+ my @l;
+ my $section = {
+ description => &{$escape}($self->mt('Usage Summary')),
+ usage_section => 1,
+ subtotal => 0,
+ };
+ foreach my $class (@classes) {
+ $section->{subtotal} += $class->get('amount');
+ push @l, {
+ 'description' => &{$escape}($class->classname),
+ 'amount' => $money_char.sprintf('%.2f', $class->get('amount')),
+ 'quantity' => $class->get('calls'),
+ 'duration' => $class->get('duration'),
+ 'usage_classnum' => $class->classnum,
+ 'section' => $section,
+ };
}
- @b;
+ $section->{subtotal} = $money_char.sprintf('%.2f', $section->{subtotal});
+ return @l;
}
sub _items_previous {
my $self = shift;
+ my $conf = $self->conf;
my $cust_main = $self->cust_main;
my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
my @b = ();
foreach ( @pr_cust_bill ) {
+ my $date = $conf->exists('invoice_show_prior_due_date')
+ ? 'due '. $_->due_date2str('short')
+ : $self->time2str_local('short', $_->_date);
push @b, {
- 'description' => 'Previous Balance, Invoice #'. $_->invnum.
- ' ('. time2str('%x',$_->_date). ')',
+ 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
#'pkgpart' => 'N/A',
'pkgnum' => 'N/A',
'amount' => sprintf("%.2f", $_->owed),
#};
}
-sub _items_pkg {
- my $self = shift;
- my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
- $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
-}
+sub _items_credits {
+ my( $self, %opt ) = @_;
+ my $trim_len = $opt{'trim_len'} || 40;
-sub _taxsort {
- return 0 unless $a cmp $b;
- return -1 if $b eq 'Tax';
- return 1 if $a eq 'Tax';
- return -1 if $b eq 'Other surcharges';
- return 1 if $a eq 'Other surcharges';
- $a cmp $b;
-}
+ my @b;
+ #credits
+ my @objects;
+ if ( $self->conf->exists('previous_balance-payments_since') ) {
+ if ( $opt{'template'} eq 'statement' ) {
+ # then the current bill is a "statement" (i.e. an invoice sent as
+ # a payment receipt)
+ # and in that case we want to see payments on or after THIS invoice
+ @objects = qsearch('cust_credit', {
+ 'custnum' => $self->custnum,
+ '_date' => {op => '>=', value => $self->_date},
+ });
+ } else {
+ my $date = 0;
+ $date = $self->previous_bill->_date if $self->previous_bill;
+ @objects = qsearch('cust_credit', {
+ 'custnum' => $self->custnum,
+ '_date' => {op => '>=', value => $date},
+ });
+ }
+ } else {
+ @objects = $self->cust_credited;
+ }
+
+ foreach my $obj ( @objects ) {
+ my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit;
+
+ my $reason = substr($cust_credit->reason, 0, $trim_len);
+ $reason .= '...' if length($reason) < length($cust_credit->reason);
+ $reason = " ($reason) " if $reason;
+
+ push @b, {
+ #'description' => 'Credit ref\#'. $_->crednum.
+ # " (". time2str("%x",$_->cust_credit->_date) .")".
+ # $reason,
+ 'description' => $self->mt('Credit applied').' '.
+ $self->time2str_local('short', $obj->_date). $reason,
+ 'amount' => sprintf("%.2f",$obj->amount),
+ };
+ }
+
+ @b;
-sub _items_tax {
- my $self = shift;
- my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
- $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
}
-sub _items_cust_bill_pkg {
+sub _items_payments {
my $self = shift;
- my $cust_bill_pkg = shift;
my %opt = @_;
- my $format = $opt{format} || '';
- my $escape_function = $opt{escape_function} || sub { shift };
- my $format_function = $opt{format_function} || '';
- my $unsquelched = $opt{unsquelched} || '';
- my $section = $opt{section}->{description} if $opt{section};
+ my @b;
+ my $detailed = $self->conf->exists('invoice_payment_details');
+ my @objects;
+ if ( $self->conf->exists('previous_balance-payments_since') ) {
+ # then show payments dated on/after the previous bill...
+ if ( $opt{'template'} eq 'statement' ) {
+ # then the current bill is a "statement" (i.e. an invoice sent as
+ # a payment receipt)
+ # and in that case we want to see payments on or after THIS invoice
+ @objects = qsearch('cust_pay', {
+ 'custnum' => $self->custnum,
+ '_date' => {op => '>=', value => $self->_date},
+ });
+ } else {
+ # the normal case: payments on or after the previous invoice
+ my $date = 0;
+ $date = $self->previous_bill->_date if $self->previous_bill;
+ @objects = qsearch('cust_pay', {
+ 'custnum' => $self->custnum,
+ '_date' => {op => '>=', value => $date},
+ });
+ # and before the current bill...
+ @objects = grep { $_->_date < $self->_date } @objects;
+ }
+ } else {
+ @objects = $self->cust_bill_pay;
+ }
- my @b = ();
- foreach my $cust_bill_pkg ( @$cust_bill_pkg )
- {
- foreach my $display ( grep { defined($section)
- ? $_->section eq $section
- : 1
- }
- $cust_bill_pkg->cust_bill_pkg_display
- )
- {
-
- my $type = $display->type;
-
- my $cust_pkg = $cust_bill_pkg->cust_pkg;
-
- my $desc = $cust_bill_pkg->desc;
- $desc = substr($desc, 0, 50). '...'
- if $format eq 'latex' && length($desc) > 50;
-
- my %details_opt = ( 'format' => $format,
- 'escape_function' => $escape_function,
- 'format_function' => $format_function,
- );
-
- if ( $cust_bill_pkg->pkgnum > 0 ) {
-
- if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) {
-
- my $description = $desc;
- $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-
- my @d = ();
- push @d, map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date)
- unless $cust_pkg->part_pkg->hide_svc_detail;
- push @d, $cust_bill_pkg->details(%details_opt)
- if $cust_bill_pkg->recur == 0;
-
- push @b, {
- description => $description,
- #pkgpart => $part_pkg->pkgpart,
- pkgnum => $cust_bill_pkg->pkgnum,
- amount => sprintf("%.2f", $cust_bill_pkg->setup),
- unit_amount => sprintf("%.2f", $cust_bill_pkg->unitsetup),
- quantity => $cust_bill_pkg->quantity,
- ext_description => \@d,
- };
+ foreach my $obj (@objects) {
+ my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay;
+ my $desc = $self->mt('Payment received').' '.
+ $self->time2str_local('short', $cust_pay->_date );
+ $desc .= $self->mt(' via ') .
+ $cust_pay->payby_payinfo_pretty( $self->cust_main->locale )
+ if $detailed;
- }
+ push @b, {
+ 'description' => $desc,
+ 'amount' => sprintf("%.2f", $obj->amount )
+ };
+ }
- if ( $cust_bill_pkg->recur != 0 &&
- ( !$type || $type eq 'R' || $type eq 'U' )
- )
- {
+ @b;
- my $is_summary = $display->summary;
- my $description = $is_summary ? "Usage charges" : $desc;
+}
- unless ( $conf->exists('disable_line_item_date_ranges') ) {
- $description .= " (" . time2str("%x", $cust_bill_pkg->sdate).
- " - ". time2str("%x", $cust_bill_pkg->edate). ")";
- }
+sub _items_total {
+ my $self = shift;
+ my $conf = $self->conf;
- my @d = ();
+ my @items;
+ my ($pr_total) = $self->previous;
+ my ($previous_charges_desc, $new_charges_desc, $new_charges_amount);
- #at least until cust_bill_pkg has "past" ranges in addition to
- #the "future" sdate/edate ones... see #3032
- push @d, map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date)
- #$cust_bill_pkg->edate,
- #$cust_bill_pkg->sdate)
- unless $cust_pkg->part_pkg->hide_svc_detail
- || $cust_bill_pkg->itemdesc
- || $is_summary;
+ if ( $conf->exists('previous_balance-exclude_from_total') ) {
+ # can we do some caching on this stuff? it's going to change infrequently
+ # in production
+ $previous_charges_desc = $self->mt(
+ $conf->config('previous_balance-text') || 'Previous Balance'
+ );
- push @d, $cust_bill_pkg->details(%details_opt)
- unless ($is_summary || $type && $type eq 'R');
-
- my $amount = 0;
- if (!$type) {
- $amount = $cust_bill_pkg->recur;
- }elsif($type eq 'R') {
- $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
- }elsif($type eq 'U') {
- $amount = $cust_bill_pkg->usage;
- }
-
- push @b, {
- description => $description,
- #pkgpart => $part_pkg->pkgpart,
- pkgnum => $cust_bill_pkg->pkgnum,
- amount => sprintf("%.2f", $amount),
- unit_amount => sprintf("%.2f", $cust_bill_pkg->unitrecur),
- quantity => $cust_bill_pkg->quantity,
- ext_description => \@d,
- } unless ( $type eq 'U' && ! $amount );
+ # then return separate lines for previous balance and total new charges
+ if ( $pr_total ) {
+ push @items,
+ { total_item => $previous_charges_desc,
+ total_amount => sprintf('%.2f',$pr_total)
+ };
+ }
+ $new_charges_desc = $self->mt(
+ $conf->config('previous_balance-text-total_new_charges')
+ || 'Total New Charges'
+ );
- }
+ $new_charges_amount = $self->charged;
- } else { #pkgnum tax or one-shot line item (??)
+ } else {
- if ( $cust_bill_pkg->setup != 0 ) {
- push @b, {
- 'description' => $desc,
- 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
- };
- }
- if ( $cust_bill_pkg->recur != 0 ) {
- push @b, {
- 'description' => "$desc (".
- time2str("%x", $cust_bill_pkg->sdate). ' - '.
- time2str("%x", $cust_bill_pkg->edate). ')',
- 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
- };
- }
+ $new_charges_desc = $self->mt('Total Charges');
+ $new_charges_amount = sprintf('%.2f',$self->charged + $pr_total);
- }
+ }
+ if ( $conf->exists('invoice_show_prior_due_date') ) {
+ # then the due date should be shown with Total New Charges,
+ # and should NOT be shown with the Balance Due message.
+ if ( $self->due_date ) {
+ # localize the "Please pay by" message and the date itself
+ # (grammar issues with this, yeah)
+ $new_charges_desc .= ' - ' . $self->mt('Please pay by') . ' ' .
+ $self->due_date2str('short');
+ } elsif ( $self->terms ) {
+ # phrases like "due on receipt" should be localized
+ $new_charges_desc .= ' - ' . $self->mt($self->terms);
}
-
}
- @b;
+ push @items,
+ { total_item => $new_charges_desc,
+ total_amount => $new_charges_amount,
+ };
+ @items;
}
-sub _items_credits {
- my( $self, %opt ) = @_;
- my $trim_len = $opt{'trim_len'} || 60;
- my @b;
- #credits
- foreach ( $self->cust_credited ) {
-
- #something more elaborate if $_->amount ne $_->cust_credit->credited ?
-
- my $reason = substr($_->cust_credit->reason, 0, $trim_len);
- $reason .= '...' if length($reason) < length($_->cust_credit->reason);
- $reason = " ($reason) " if $reason;
- push @b, {
- #'description' => 'Credit ref\#'. $_->crednum.
- # " (". time2str("%x",$_->cust_credit->_date) .")".
- # $reason,
- 'description' => 'Credit applied '.
- time2str("%x",$_->cust_credit->_date). $reason,
- 'amount' => sprintf("%.2f",$_->amount),
- };
- }
+=item call_details [ OPTION => VALUE ... ]
- @b;
+Returns an array of CSV strings representing the call details for this invoice
+The only option available is the boolean prepend_billed_number
-}
+=cut
-sub _items_payments {
- my $self = shift;
+sub call_details {
+ my ($self, %opt) = @_;
- my @b;
- #get & print payments
- foreach ( $self->cust_bill_pay ) {
+ my $format_function = sub { shift };
- #something more elaborate if $_->amount ne ->cust_pay->paid ?
+ if ($opt{prepend_billed_number}) {
+ $format_function = sub {
+ my $detail = shift;
+ my $row = shift;
- push @b, {
- 'description' => "Payment received ".
- time2str("%x",$_->cust_pay->_date ),
- 'amount' => sprintf("%.2f", $_->amount )
+ $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
+
};
}
- @b;
-
+ my @details = map { $_->details( 'format_function' => $format_function,
+ 'escape_function' => sub{ return() },
+ )
+ }
+ grep { $_->pkgnum }
+ $self->cust_bill_pkg;
+ my $header = $details[0];
+ ( $header, grep { $_ ne $header } @details );
}
+=item cust_pay_batch
+
+Returns all L<FS::cust_pay_batch> records linked to this invoice. Deprecated,
+will be removed.
+
+=cut
+
+sub cust_pay_batch {
+ carp "FS::cust_bill->cust_pay_batch is deprecated";
+ my $self = shift;
+ qsearch('cust_pay_batch', { 'invnum' => $self->invnum });
+}
=back
process_re_X('spool', @_);
}
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_re_X {
my( $method, $job ) = ( shift, shift );
warn "$me process_re_X $method for job $job\n" if $DEBUG;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
re_X(
}
+# this is called from search/cust_bill.html and given all its search
+# parameters, so it needs to perform the same search.
+
sub re_X {
+ # spool_invoice ftp_invoice fax_invoice print_invoice
my($method, $job, %param ) = @_;
if ( $DEBUG ) {
warn "re_X $method for job $job with param:\n".
}
#some false laziness w/search/cust_bill.html
- my $distinct = '';
- my $orderby = 'ORDER BY cust_bill._date';
-
- my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
-
- my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
-
- my @cust_bill = qsearch( {
- #'select' => "cust_bill.*",
- 'table' => 'cust_bill',
- 'addl_from' => $addl_from,
- 'hashref' => {},
- 'extra_sql' => $extra_sql,
- 'order_by' => $orderby,
- 'debug' => 1,
- } );
+ $param{'order_by'} = 'cust_bill._date';
+
+ my $query = FS::cust_bill->search(\%param);
+ delete $query->{'count_query'};
+ delete $query->{'count_addl'};
+
+ $query->{debug} = 1; # was in here before, is obviously useful
+
+ my @cust_bill = qsearch( $query );
$method .= '_invoice' unless $method eq 'email' || $method eq 'print';
}
+sub API_getinfo {
+ my $self = shift;
+ +{ ( map { $_=>$self->$_ } $self->fields ),
+ 'owed' => $self->owed,
+ #XXX last payment applied date
+ };
+}
+
=back
=head1 CLASS METHODS
=cut
sub owed_sql {
- my $class = shift;
- 'charged - '. $class->paid_sql. ' - '. $class->credited_sql;
+ my ($class, $start, $end) = @_;
+ 'charged - '.
+ $class->paid_sql($start, $end). ' - '.
+ $class->credited_sql($start, $end);
}
=item net_sql
=cut
sub net_sql {
- my $class = shift;
- 'charged - '. $class->credited_sql;
+ my ($class, $start, $end) = @_;
+ 'charged - '. $class->credited_sql($start, $end);
}
=item paid_sql
=cut
sub paid_sql {
- #my $class = shift;
+ my ($class, $start, $end) = @_;
+ $start &&= "AND cust_bill_pay._date <= $start";
+ $end &&= "AND cust_bill_pay._date > $end";
+ $start = '' unless defined($start);
+ $end = '' unless defined($end);
"( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
- WHERE cust_bill.invnum = cust_bill_pay.invnum )";
+ WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )";
}
=item credited_sql
=cut
sub credited_sql {
- #my $class = shift;
+ my ($class, $start, $end) = @_;
+ $start &&= "AND cust_credit_bill._date <= $start";
+ $end &&= "AND cust_credit_bill._date > $end";
+ $start = '' unless defined($start);
+ $end = '' unless defined($end);
"( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
- WHERE cust_bill.invnum = cust_credit_bill.invnum )";
+ WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
}
-=item search_sql HASHREF
-
-Class method which returns an SQL WHERE fragment to search for parameters
-specified in HASHREF. Valid parameters are
-
-=over 4
-
-=item begin
-
-Epoch date (UNIX timestamp) setting a lower bound for _date values
-
-=item end
-
-Epoch date (UNIX timestamp) setting an upper bound for _date values
-
-=item invnum_min
-
-=item invnum_max
-
-=item agentnum
-
-=item owed
-
-=item net
-
-=item days
-
-=item newest_percust
-
-=back
+=item due_date_sql
-Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+Returns an SQL fragment to retrieve the due date of an invoice.
+Currently only supported on PostgreSQL.
=cut
-sub search_sql {
- my($class, $param) = @_;
- if ( $DEBUG ) {
- warn "$me search_sql called with params: \n".
- join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
- }
-
- my @search = ();
-
- if ( $param->{'begin'} =~ /^(\d+)$/ ) {
- push @search, "cust_bill._date >= $1";
- }
- if ( $param->{'end'} =~ /^(\d+)$/ ) {
- push @search, "cust_bill._date < $1";
- }
- if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
- push @search, "cust_bill.invnum >= $1";
- }
- if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
- push @search, "cust_bill.invnum <= $1";
- }
- if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
- push @search, "cust_main.agentnum = $1";
- }
-
- push @search, '0 != '. FS::cust_bill->owed_sql
- if $param->{'open'};
-
- push @search, '0 != '. FS::cust_bill->net_sql
- if $param->{'net'};
-
- push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
- if $param->{'days'};
-
- if ( $param->{'newest_percust'} ) {
-
- #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
- #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
-
- my @newest_where = map { my $x = $_;
- $x =~ s/\bcust_bill\./newest_cust_bill./g;
- $x;
- }
- grep ! /^cust_main./, @search;
- my $newest_where = scalar(@newest_where)
- ? ' AND '. join(' AND ', @newest_where)
- : '';
-
-
- push @search, "cust_bill._date = (
- SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
- WHERE newest_cust_bill.custnum = cust_bill.custnum
- $newest_where
- )";
-
- }
-
- my $curuser = $FS::CurrentUser::CurrentUser;
- if ( $curuser->username eq 'fs_queue'
- && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
- my $username = $1;
- my $newuser = qsearchs('access_user', {
- 'username' => $username,
- 'disabled' => '',
- } );
- if ( $newuser ) {
- $curuser = $newuser;
- } else {
- warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
- }
- }
-
- push @search, $curuser->agentnums_sql;
-
- join(' AND ', @search );
-
+sub due_date_sql {
+ die "don't use: doesn't account for agent-specific invoice_default_terms";
+
+ #we're passed a $conf but not a specific customer (that's in the query), so
+ # to make this work we'd need an agentnum-aware "condition_sql_conf" like
+ # "condition_sql_option" that retreives a conf value with SQL in an agent-
+ # aware fashion
+
+ my $conf = new FS::Conf;
+'COALESCE(
+ SUBSTRING(
+ COALESCE(
+ cust_bill.invoice_terms,
+ cust_main.invoice_terms,
+ \''.($conf->config('invoice_default_terms') || '').'\'
+ ), E\'Net (\\\\d+)\'
+ )::INTEGER, 0
+) * 86400 + cust_bill._date'
}
=back