package FS::cust_bill;
use strict;
-use vars qw( @ISA $DEBUG $conf $money_char );
+use vars qw( @ISA $DEBUG $me $conf $money_char );
use vars qw( $invoice_lines @buf ); #yuck
use Fcntl qw(:flock); #for spool_csv
use IPC::Run3;
use Locale::Country;
use FS::UID qw( datasrc );
use FS::Misc qw( send_email send_fax );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
use FS::cust_main_Mixin;
use FS::cust_main;
use FS::cust_bill_pkg;
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::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 );
$DEBUG = 0;
+$me = '[FS::cust_bill]';
#ask FS::UID to run this stuff for us later
FS::UID->install_callback( sub {
=item delete
-Currently unimplemented. I don't remove invoices because there would then be
-no record you ever posted this invoice (which is bad, no?)
+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.
+
+Really, don't use it.
=cut
=cut
-sub replace {
+#replace can be inherited from Record.pm
+
+# replace_check is now the preferred way to #implement replace data checks
+# (so $object->replace() works without an argument)
+
+sub replace_check {
my( $new, $old ) = ( shift, shift );
return "Can't change custnum!" unless $old->custnum == $new->custnum;
#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;
+ return "Can't change charged!" unless $old->charged == $new->charged
+ || $old->charged == 0;
- $new->SUPER::replace($old);
+ '';
}
=item check
qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } );
}
+=item open_cust_bill_pkg
+
+Returns the open line items for this invoice.
+
+Note that cust_bill_pkg with both setup and recur fees are returned as two
+separate line items, each with only one fee.
+
+=cut
+
+# modeled after cust_main::open_cust_bill
+sub open_cust_bill_pkg {
+ my $self = shift;
+
+ # grep { $_->owed > 0 } $self->cust_bill_pkg
+
+ my %other = ( 'recur' => 'setup',
+ 'setup' => 'recur', );
+ my @open = ();
+ foreach my $field ( qw( recur setup )) {
+ push @open, map { $_->set( $other{$field}, 0 ); $_; }
+ grep { $_->owed($field) > 0 }
+ $self->cust_bill_pkg;
+ }
+
+ @open;
+}
+
=item cust_bill_event
Returns the completed invoice events (see L<FS::cust_bill_event>) for this
qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
}
+=item cust_suspend_if_balance_over AMOUNT
+
+Suspends the customer associated with this invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
+
+Returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cust_suspend_if_balance_over {
+ my( $self, $amount ) = ( shift, shift );
+ my $cust_main = $self->cust_main;
+ if ( $cust_main->total_owed_date($self->_date) < $amount ) {
+ return ();
+ } else {
+ $cust_main->suspend(@_);
+ }
+}
+
=item cust_credit
Depreciated. See the cust_credited method.
username
password
dir
-format - 'default' or 'billco'
-
-#???
-If I<format> is not specified or "default", the file will be named
-"N-YYYYMMDDHHMMSS.csv" where N is the invoice number and YYMMDDHHMMSS is a
-timestamp.
-#???
-If I<format> is "billco", two files will be created and uploaded. They will be named "N-YYYYMMDDHHMMSS-header.csv" and "N-YYYYMMDDHHMMSS-detail.csv" where N
-is the invoice number and YYMMDDHHMMSS is a timestamp(???).
+The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
+and YYMMDDHHMMSS is a timestamp.
See L</print_csv> for a description of the output format.
mkdir $spooldir, 0700 unless -d $spooldir;
my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
- my $file = "$spooldir/$tracctnum";
- if ( lc($opt{'format'}) eq 'billco' ) {
- $file .= '-header.csv';
- } else {
- #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
- $file .= '.csv';
- }
+ my $file = "$spooldir/$tracctnum.csv";
my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
open(CSV, ">$file") or die "can't open $file: $!";
print CSV $header;
- my $oldfile = '';
- if ( lc($opt{'format'}) eq 'billco' ) {
- close CSV;
- $oldfile = $file;
- $file = "$spooldir/$tracctnum-detail.csv";
- open(CSV,">$file") or die "can't open $file: $!";
- }
-
print CSV $detail;
close CSV;
$net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
- if ( $oldfile) {
- $net->put($oldfile) or die "can't put $oldfile: $!";
- }
$net->put($file) or die "can't put $file: $!";
$net->quit;
- unlink $oldfile if $oldfile;
unlink $file;
}
=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 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.
+
=back
=cut
sub spool_csv {
my($self, %opt) = @_;
+ my $cust_main = $self->cust_main;
+
if ( $opt{'dest'} ) {
my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
- $self->cust_main->invoicing_list;
- return unless $invoicing_list{$opt{'dest'}};
+ $cust_main->invoicing_list;
+ return 'N/A' unless $invoicing_list{$opt{'dest'}}
+ || ! keys %invoicing_list;
}
- #create file(s)
+ if ( $opt{'balanceover'} ) {
+ return 'N/A'
+ if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
+ }
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 $file = "$spooldir/spool";
- if ( lc($opt{'format'}) eq 'billco' ) {
- $file .= '-header.csv';
- } else {
- #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
- $file .= '.csv';
- }
+
+ my $file =
+ "$spooldir/".
+ ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+ ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
+ '.csv';
my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
print CSV $header;
- my $oldfile = '';
if ( lc($opt{'format'}) eq 'billco' ) {
flock(CSV, LOCK_UN);
close CSV;
- $oldfile = $file;
- $file = "$spooldir/spool-detail.csv";
+ $file =
+ "$spooldir/".
+ ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+ '-detail.csv';
open(CSV,">>$file") or die "can't open $file: $!";
flock(CSV, LOCK_EX);
}
-=item batch_card
+=item batch_card OPTION => VALUE...
Adds a payment for this invoice to the pending credit card batch (see
-L<FS::cust_pay_batch>).
+L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
+runs the payment using a realtime gateway.
=cut
sub batch_card {
- my $self = shift;
+ my ($self, %options) = @_;
my $cust_main = $self->cust_main;
+ my $amount = sprintf("%.2f", $cust_main->balance - $cust_main->in_transit_payments);
+ return '' unless $amount > 0;
+
+ if ($options{'realtime'}) {
+ return $cust_main->realtime_bop( FS::payby->payby2bop($cust_main->payby),
+ $amount,
+ %options,
+ );
+ }
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
+ or return "Cannot lock pay_batch: " . $dbh->errstr;
+
+ my %pay_batch = (
+ 'status' => 'O',
+ 'payby' => FS::payby->payby2payment($cust_main->payby),
+ );
+
+ my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
+
+ unless ( $pay_batch ) {
+ $pay_batch = new FS::pay_batch \%pay_batch;
+ my $error = $pay_batch->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ die "error creating new batch: $error\n";
+ }
+ }
+
+ my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
+ 'batchnum' => $pay_batch->batchnum,
+ 'custnum' => $cust_main->custnum,
+ } );
+
my $cust_pay_batch = new FS::cust_pay_batch ( {
- 'invnum' => $self->getfield('invnum'),
- 'custnum' => $cust_main->getfield('custnum'),
+ 'batchnum' => $pay_batch->batchnum,
+ 'invnum' => $self->getfield('invnum'), # is there a better value?
+ # this field should be
+ # removed...
+ # cust_bill_pay_batch now
+ 'custnum' => $cust_main->custnum,
'last' => $cust_main->getfield('last'),
'first' => $cust_main->getfield('first'),
- 'address1' => $cust_main->getfield('address1'),
- 'address2' => $cust_main->getfield('address2'),
- 'city' => $cust_main->getfield('city'),
- 'state' => $cust_main->getfield('state'),
- 'zip' => $cust_main->getfield('zip'),
- 'country' => $cust_main->getfield('country'),
- 'cardnum' => $cust_main->payinfo,
- 'exp' => $cust_main->getfield('paydate'),
- 'payname' => $cust_main->getfield('payname'),
- 'amount' => $self->owed,
+ 'address1' => $cust_main->address1,
+ 'address2' => $cust_main->address2,
+ 'city' => $cust_main->city,
+ 'state' => $cust_main->state,
+ 'zip' => $cust_main->zip,
+ 'country' => $cust_main->country,
+ 'payby' => $cust_main->payby,
+ 'payinfo' => $cust_main->payinfo,
+ 'exp' => $cust_main->paydate,
+ 'payname' => $cust_main->payname,
+ 'amount' => $amount, # consolidating
} );
- my $error = $cust_pay_batch->insert;
- die $error if $error;
+
+ $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
+ if $old_cust_pay_batch;
+
+ my $error;
+ if ($old_cust_pay_batch) {
+ $error = $cust_pay_batch->replace($old_cust_pay_batch)
+ } else {
+ $error = $cust_pay_batch->insert;
+ }
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+
+ my $unapplied = $cust_main->total_credited + $cust_main->total_unapplied_payments + $cust_main->in_transit_payments;
+ foreach my $cust_bill ($cust_main->open_cust_bill) {
+ #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
+ 'invnum' => $cust_bill->invnum,
+ 'paybatchnum' => $cust_pay_batch->paybatchnum,
+ 'amount' => $cust_bill->owed,
+ '_date' => time,
+ };
+ if ($unapplied >= $cust_bill_pay_batch->amount){
+ $unapplied -= $cust_bill_pay_batch->amount;
+ next;
+ }else{
+ $cust_bill_pay_batch->amount(sprintf ( "%.2f",
+ $cust_bill_pay_batch->amount - $unapplied ));
+ $unapplied = 0;
+ }
+ $error = $cust_bill_pay_batch->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
'';
}
}
+
=back
=head1 SUBROUTINES
use MIME::Base64;
sub process_re_X {
my( $method, $job ) = ( shift, shift );
+ warn "process_re_X $method for job $job\n" if $DEBUG;
my $param = thaw(decode_base64(shift));
warn Dumper($param) if $DEBUG;
sub re_X {
my($method, $job, %param ) = @_;
# [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ],
+ if ( $DEBUG ) {
+ warn "re_X $method for job $job with param:\n".
+ join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
+ }
#some false laziness w/search/cust_bill.html
my $distinct = '';