require 5.006;
use strict;
-use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
- $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
+use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf
+ @encrypted_fields
+ $import $ignore_expired_card
+ $skip_fuzzyfiles @fuzzyfields
+ @paytypes
+ );
use vars qw( $realtime_bop_decline_quiet ); #ugh
use Safe;
use Carp;
$me = '[FS::cust_main]';
$import = 0;
-$skip_fuzzyfiles = 0;
$ignore_expired_card = 0;
+$skip_fuzzyfiles = 0;
+@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
+
@encrypted_fields = ('payinfo', 'paycvv');
sub nohistory_fields { ('paycvv'); }
my $dbh = dbh;
my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
- my $error = $queue->insert( map $self->getfield($_),
- qw(first last company)
- );
+ my $error = $queue->insert( map $self->getfield($_), @fuzzyfields );
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return "queueing job (transaction rolled back): $error";
if ( $self->ship_last ) {
$queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
- $error = $queue->insert( map $self->getfield("ship_$_"),
- qw(first last company)
- );
+ $error = $queue->insert( map $self->getfield("ship_$_"), @fuzzyfields );
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return "queueing job (transaction rolled back): $error";
unless ! $self->referral_custnum
|| qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
+ if ( $self->censustract ne '' ) {
+ $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
+ or return "Illegal census tract: ". $self->censustract;
+
+ $self->censustract("$1.$2");
+ }
+
if ( $self->ss eq '' ) {
$self->ss('');
} else {
my( $m, $y );
if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+ } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+ ( $m, $y ) = ( $2, "19$1" );
} elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
( $m, $y ) = ( $3, "20$2" );
} else {
$self->payname($1);
}
- foreach my $flag (qw( tax spool_cdr squelch_cdr archived )) {
+ foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
$self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
$self->$flag($1);
}
scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
}
+=item location_hash
+
+Returns a list of key/value pairs, with the following keys: address1, adddress2,
+city, county, state, zip, country. The shipping address is used if present.
+
+=cut
+
+#geocode? dependent on tax-ship_address config, not available in cust_location
+#mostly. not yet then.
+
+sub location_hash {
+ my $self = shift;
+ my $prefix = $self->has_ship_address ? 'ship_' : '';
+
+ map { $_ => $self->get($prefix.$_) }
+ qw( address1 address2 city county state zip country geocode );
+ #fields that cust_location has
+}
+
=item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
Returns all packages (see L<FS::cust_pkg>) for this customer.
qsearch('cust_location', { 'custnum' => $self->custnum } );
}
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+# false laziness with FS::cust_location::line
+
+sub location_label {
+ my $self = shift;
+ my %opt = @_;
+
+ my $separator = $opt{join_string} || ', ';
+ my $escape = $opt{escape_function} || sub{ shift };
+ my $line = '';
+ my $cydefault = FS::conf->new->config('countrydefault') || 'US';
+ my $prefix = length($self->ship_last) ? 'ship_' : '';
+
+ my $notfirst = 0;
+ foreach (qw ( address1 address2 ) ) {
+ my $method = "$prefix$_";
+ $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+ if $self->$method;
+ $notfirst++;
+ }
+ $notfirst = 0;
+ foreach (qw ( city county state zip ) ) {
+ my $method = "$prefix$_";
+ if ( $self->$method ) {
+ $line .= ' (' if $method eq 'county';
+ $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+ $line .= ' )' if $method eq 'county';
+ $notfirst++;
+ }
+ }
+ $line .= $separator. &$escape(code2country($self->country))
+ if $self->country ne $cydefault;
+
+ $line;
+}
+
=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
# This should be generalized to use config options to determine order.
sub sort_packages {
+ my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
+ return $locationsort if $locationsort;
+
if ( $a->get('cancel') xor $b->get('cancel') ) {
return -1 if $b->get('cancel');
return 1 if $a->get('cancel');
=item bill_and_collect
Cancels and suspends any packages due, generates bills, applies payments and
-cred
+credits, and applies collection events to run cards, send bills and notices,
+etc.
-Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
Options are passed as name-value pairs. Currently available options are:
If set true, re-charges setup fees.
+=item fatal
+
+If set any errors prevent subsequent operations from continusing. If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
=item debug
Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
sub bill_and_collect {
my( $self, %options ) = @_;
+ my $error;
+
#$options{actual_time} not $options{time} because freeside-daily -d is for
#pre-printing invoices
- $self->cancel_expired_pkgs( $options{actual_time} );
- $self->suspend_adjourned_pkgs( $options{actual_time} );
- my $error = $self->bill( %options );
- warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+ $options{'actual_time'} ||= time;
+
+ $error = $self->cancel_expired_pkgs( $options{actual_time} );
+ if ( $error ) {
+ $error = "Error expiring custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+ if ( $error ) {
+ $error = "Error adjourning custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
- $self->apply_payments_and_credits;
+ $error = $self->bill( %options );
+ if ( $error ) {
+ $error = "Error billing custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $error = $self->apply_payments_and_credits;
+ if ( $error ) {
+ $error = "Error applying custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
unless ( $conf->exists('cancelled_cust-noevents')
&& ! $self->num_ncancelled_pkgs
) {
-
$error = $self->collect( %options );
- warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
+ if ( $error ) {
+ $error = "Error collecting custnum ". $self->custnum. ": $error";
+ if ($options{fatal} && $options{fatal} eq 'return') { return $error; }
+ elsif ($options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
}
+ '';
+
}
sub cancel_expired_pkgs {
- my ( $self, $time ) = @_;
+ my ( $self, $time, %options ) = @_;
my @cancel_pkgs = $self->ncancelled_pkgs( {
'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
} );
+ my @errors = ();
+
foreach my $cust_pkg ( @cancel_pkgs ) {
my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
)
: ()
);
- warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
- " for custnum ". $self->custnum. ": $error"
- if $error;
+ push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
}
+ scalar(@errors) ? join(' / ', @errors) : '';
+
}
sub suspend_adjourned_pkgs {
- my ( $self, $time ) = @_;
+ my ( $self, $time, %options ) = @_;
my @susp_pkgs = $self->ncancelled_pkgs( {
'extra_sql' =>
}
@susp_pkgs;
+ my @errors = ();
+
foreach my $cust_pkg ( @susp_pkgs ) {
my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
)
: ()
);
-
- warn "Error suspending package ". $cust_pkg->pkgnum.
- " for custnum ". $self->custnum. ": $error"
- if $error;
+ push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
}
+ scalar(@errors) ? join(' / ', @errors) : '';
+
}
=item bill OPTIONS
=item not_pkgpart
-A hashref of pkgparts to exclude from this billing run.
+A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
=item invoice_time
fees since the last billing. Setup charges may be charged. Not all package
plans support this feature (they tend to charge 0).
+=item invoice_terms
+
+Optional terms to be printed on this invoice. Otherwise, customer-specific
+terms or the default terms are used.
+
=back
=cut
my $invoice_time = $options{'invoice_time'} || $time;
$options{'not_pkgpart'} ||= {};
+ $options{'not_pkgpart'} = { map { $_ => 1 }
+ split(/\s*,\s*/, $options{'not_pkgpart'})
+ }
+ unless ref($options{'not_pkgpart'});
- #put below somehow?
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
local $SIG{QUIT} = 'IGNORE';
$self->select_for_update; #mutex
+ my $error = $self->do_cust_event(
+ 'debug' => ( $options{'debug'} || 0 ),
+ 'time' => $invoice_time,
+ 'check_freq' => $options{'check_freq'},
+ 'stage' => 'pre-bill',
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
my @cust_bill_pkg = ();
###
'recur' => \$total_recur,
'tax_matrix' => \%taxlisthash,
'time' => $time,
+ 'real_pkgpart' => $real_pkgpart,
'options' => \%options,
);
if ($error) {
} elsif ( $postal_pkg ) {
+ my $real_pkgpart = $postal_pkg->pkgpart;
foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
my %postal_options = %options;
delete $postal_options{cancel};
'recur' => \$total_recur,
'tax_matrix' => \%taxlisthash,
'time' => $time,
+ 'real_pkgpart' => $real_pkgpart,
'options' => \%postal_options,
);
if ($error) {
$tax = sprintf('%.2f', $tax );
$total_setup = sprintf('%.2f', $total_setup+$tax );
+ my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
+ 'disabled' => '',
+ },
+ );
+
+ my @display = ();
+ if ( $pkg_category and
+ $conf->config('invoice_latexsummary') ||
+ $conf->config('invoice_htmlsummary')
+ )
+ {
+
+ my %hash = ( 'section' => $pkg_category->categoryname );
+ push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+
+ }
+
push @cust_bill_pkg, new FS::cust_bill_pkg {
'pkgnum' => 0,
'setup' => $tax,
'sdate' => '',
'edate' => '',
'itemdesc' => $taxname,
+ 'display' => \@display,
'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
};
my $charged = sprintf('%.2f', $total_setup + $total_recur );
+ my @cust_bill = $self->cust_bill;
+ my $balance = $self->balance;
+ my $previous_balance = scalar(@cust_bill)
+ ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
+ : 0;
+
+ $previous_balance += $cust_bill[$#cust_bill]->charged
+ if scalar(@cust_bill);
+ #my $balance_adjustments =
+ # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+
#create the new invoice
my $cust_bill = new FS::cust_bill ( {
- 'custnum' => $self->custnum,
- '_date' => ( $invoice_time ),
- 'charged' => $charged,
+ 'custnum' => $self->custnum,
+ '_date' => ( $invoice_time ),
+ 'charged' => $charged,
+ 'billing_balance' => $balance,
+ 'previous_balance' => $previous_balance,
+ 'invoice_terms' => $options{'invoice_terms'},
} );
- my $error = $cust_bill->insert;
+ $error = $cust_bill->insert;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return "can't create invoice for customer #". $self->custnum. ": $error";
my (%options) = %{$params{options}};
my $dbh = dbh;
- my $real_pkgpart = $cust_pkg->pkgpart;
+ my $real_pkgpart = $params{real_pkgpart};
my %hash = $cust_pkg->hash;
my $old_cust_pkg = new FS::cust_pkg \%hash;
my $recur = 0;
my $unitrecur = 0;
my $sdate;
- if ( ! $cust_pkg->getfield('susp') and
- ( $part_pkg->getfield('freq') ne '0' &&
- ( $cust_pkg->getfield('bill') || 0 ) <= $time
+ if ( ! $cust_pkg->get('susp')
+ and ! $cust_pkg->get('start_date')
+ and ( $part_pkg->getfield('freq') ne '0'
+ && ( $cust_pkg->getfield('bill') || 0 ) <= $time
)
|| ( $part_pkg->plan eq 'voip_cdr'
&& $part_pkg->option('bill_every_call')
'unitrecur' => $unitrecur,
'quantity' => $cust_pkg->quantity,
'details' => \@details,
+ 'hidden' => $part_pkg->hidden,
};
if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
###
my $error =
- $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time});
+ $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
return $error if $error;
push @$cust_bill_pkgs, $cust_bill_pkg;
my $cust_bill_pkg = shift;
my $cust_pkg = shift;
my $invoice_time = shift;
+ my $real_pkgpart = shift;
+ my $options = shift;
my %cust_bill_pkg = ();
my %taxes = ();
my @classes;
#push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
- push @classes, 'setup' if $cust_bill_pkg->setup;
- push @classes, 'recur' if $cust_bill_pkg->recur;
+ push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+ push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
}
my @display = ();
- if ( $conf->exists('separate_usage') ) {
+ my $separate = $conf->exists('separate_usage');
+ my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
+ if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) {
+
+ my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
+ my %hash = $cust_bill_pkg->hidden # maybe for all bill linked?
+ ? ( 'section' => $temp_pkg->part_pkg->categoryname )
+ : ();
+
my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
- push @display, new FS::cust_bill_pkg_display { type => 'S' };
- push @display, new FS::cust_bill_pkg_display { type => 'R' };
- push @display, new FS::cust_bill_pkg_display { type => 'U',
- section => $section
- };
- if ($section && $summary) {
- $display[2]->post_total('Y');
+ if ( $separate ) {
+ push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+ push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
+ } else {
+ push @display, new FS::cust_bill_pkg_display
+ { type => '',
+ %hash,
+ ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
+ };
+ }
+
+ if ($separate && $section && $summary) {
push @display, new FS::cust_bill_pkg_display { type => 'U',
summary => 'Y',
- }
+ %hash,
+ };
+ }
+ if ($usage_mandate || $section && $summary) {
+ $hash{post_total} = 'Y';
}
+
+ $hash{section} = $section if ($separate || $usage_mandate);
+ push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+
}
$cust_bill_pkg->set('display', \@display);
}
-=item collect OPTIONS
+=item collect [ HASHREF | OPTION => VALUE ... ]
(Attempt to) collect money for this customer's outstanding invoices (see
L<FS::cust_bill>). Usually used after the bill method.
Retry card/echeck/LEC transactions even when not scheduled by invoice events.
-=item quiet
-
-set true to surpress email card/ACH decline notices.
-
=item check_freq
"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-=item payby
+=item quiet
-allows for one time override of normal customer billing method
+set true to surpress email card/ACH decline notices.
=item debug
Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
=back
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
=cut
sub collect {
}
}
+ my $error = $self->do_cust_event(
+ 'debug' => ( $options{'debug'} || 0 ),
+ 'time' => $invoice_time,
+ 'check_freq' => $options{'check_freq'},
+ 'stage' => 'collect',
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+
+}
+
+=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Runs billing events; see L<FS::part_event> and the billing events web
+interface.
+
+If there is an error, returns the error, otherwise returns false.
+
+Options are passed as name-value pairs.
+
+Currently available options are:
+
+=over 4
+
+=item time
+
+Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item stage
+
+"collect" (the default) or "pre-bill"
+
+=item quiet
+
+set true to surpress email card/ACH decline notices.
+
+=item debug
+
+Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=cut
+
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
+# =item retry
+#
+# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+sub do_cust_event {
+ my( $self, %options ) = @_;
+ my $time = $options{'time'} || time;
+
+ #put below somehow?
+ 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;
+
+ $self->select_for_update; #mutex
+
+ if ( $DEBUG ) {
+ my $balance = $self->balance;
+ warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
+ }
+
+# if ( exists($options{'retry_card'}) ) {
+# carp 'retry_card option passed to collect is deprecated; use retry';
+# $options{'retry'} ||= $options{'retry_card'};
+# }
+# if ( exists($options{'retry'}) && $options{'retry'} ) {
+# my $error = $self->retry_realtime;
+# if ( $error ) {
+# $dbh->rollback if $oldAutoCommit;
+# return $error;
+# }
+# }
+
# false laziness w/pay_batch::import_results
my $due_cust_event = $self->due_cust_event(
'debug' => ( $options{'debug'} || 0 ),
- 'time' => $invoice_time,
+ 'time' => $time,
'check_freq' => $options{'check_freq'},
+ 'stage' => ( $options{'stage'} || 'collect' ),
);
unless( ref($due_cust_event) ) {
$dbh->rollback if $oldAutoCommit;
#XXX lock event
#re-eval event conditions (a previous event could have changed things)
- unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
+ unless ( $cust_event->test_conditions( 'time' => $time ) ) {
#don't leave stray "new/locked" records around
my $error = $cust_event->delete;
if ( $error ) {
Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
+=item stage
+
+"collect" (the default) or "pre-bill"
+
=item time
"Current time" for the events.
unless $opt{testonly};
###
- # 1: find possible events (initial search)
+ # find possible events (initial search)
###
my @cust_event = ();
" total possible cust events found in initial search\n"
if $DEBUG; # > 1;
+
##
- # 2: test conditions
+ # test stage
+ ##
+
+ $opt{stage} ||= 'collect';
+ @cust_event =
+ grep { my $stage = $_->part_event->event_stage;
+ $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
+ }
+ @cust_event;
+
+ ##
+ # test conditions
##
my %unsat = ();
warn " invalid conditions not eliminated with condition_sql:\n".
join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
- if $DEBUG; # > 1;
+ if keys %unsat && $DEBUG; # > 1;
##
- # 3: insert
+ # insert
##
unless( $opt{testonly} ) {
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
##
- # 4: return
+ # return
##
warn " returning events: ". Dumper(@cust_event). "\n"
if set, will override the value from the customer record.
I<description> is a free-text field passed to the gateway. It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
If an I<invnum> is specified, this payment (if successful) is applied to the
specified invoice. If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
I<quiet> can be set true to surpress email decline notices.
return $self->_new_realtime_bop(@_)
if $self->_new_bop_required();
- my( $method, $amount, %options ) = @_;
+ my($method, $amount);
+ my %options = ();
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ $method = $options{method};
+ $amount = $options{amount};
+ } else {
+ ( $method, $amount ) = ( shift, shift );
+ %options = @_;
+ }
if ( $DEBUG ) {
warn "$me realtime_bop: $method $amount\n";
warn " $_ => $options{$_}\n" foreach keys %options;
}
- $options{'description'} ||= 'Internet services';
+ return "Amount must be greater than 0" unless $amount > 0;
+
+ unless ( $options{'description'} ) {
+ if ( $conf->exists('business-onlinepayment-description') ) {
+ my $dtempl = $conf->config('business-onlinepayment-description');
+
+ my $agent = $self->agent->agent;
+ #$pkgs... not here
+ $options{'description'} = eval qq("$dtempl");
+ } else {
+ $options{'description'} = 'Internet services';
+ }
+ }
return $self->fake_bop($method, $amount, %options) if $options{'fake'};
'payinfo' => $payinfo,
'paydate' => $paydate,
'recurring_billing' => $content{recurring_billing},
+ 'pkgnum' => $options{'pkgnum'},
'status' => 'new',
'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
};
'payinfo' => $payinfo,
'paybatch' => $paybatch,
'paydate' => $paydate,
+ 'pkgnum' => $options{'pkgnum'},
} );
#doesn't hurt to know, even though the dup check is in cust_pay_pending now
$cust_pay->payunique( $options{payunique} )
} else {
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ if ( $options{'apply'} ) {
+ my $apply_error = $self->apply_payments_and_credits;
+ if ( $apply_error ) {
+ warn "WARNING: error applying payment: $apply_error\n";
+ #but we still should return no error cause the payment otherwise went
+ #through...
+ }
+ }
+
return ''; #no error
}
$template->compile()
or return "($perror) can't compile template: $Text::Template::ERROR";
- my $templ_hash = { error => $transaction->error_message };
+ my $templ_hash = {
+ 'company_name' =>
+ scalar( $conf->config('company_name', $self->agentnum ) ),
+ 'company_address' =>
+ join("\n", $conf->config('company_address', $self->agentnum ) ),
+ 'error' => $transaction->error_message,
+ };
my $error = send_email(
'from' => $conf->config('invoice_from', $self->agentnum ),
sub _bop_recurring_billing {
my( $self, %opt ) = @_;
- my $method = $conf->config('credit_card-recurring_billing_flag');
+ my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
- if ( $method eq 'transaction_is_recur' ) {
+ if ( defined($method) && $method eq 'transaction_is_recur' ) {
return 1 if $opt{'trans_is_recur'};
) {
warn " attempting void\n" if $DEBUG > 1;
my $void = new Business::OnlinePayment( $processor, @bop_options );
+ if ( $void->can('info') ) {
+ if ( $cust_pay->payby eq 'CARD'
+ && $void->info('CC_void_requires_card') )
+ {
+ $content{'card_number'} = $cust_pay->payinfo
+ } elsif ( $cust_pay->payby eq 'CHEK'
+ && $void->info('ECHECK_void_requires_account') )
+ {
+ ( $content{'account_number'}, $content{'routing_code'} ) =
+ split('@', $cust_pay->payinfo);
+ $content{'name'} = $self->get('first'). ' '. $self->get('last');
+ }
+ }
$void->content( 'action' => 'void', %content );
$void->submit();
if ( $void->is_success ) {
my $botpp = 'Business::OnlineThirdPartyPayment';
return 1
- if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
- scalar( grep { $_->gateway_namespace eq $botpp }
- qsearch( 'payment_gateway', { 'disabled' => '' } )
- )
+ if ( ( $conf->exists('business-onlinepayment-namespace')
+ && $conf->config('business-onlinepayment-namespace') eq $botpp
+ )
+ or scalar( grep { $_->gateway_namespace eq $botpp }
+ qsearch( 'payment_gateway', { 'disabled' => '' } )
+ )
)
;
'';
}
-
=item realtime_collect [ OPTION => VALUE ... ]
Runs a realtime credit card, ACH (electronic check) or phone bill transaction
Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
-Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified
then it is deduced from the customer record.
if set, will override the value from the customer record.
I<description> is a free-text field passed to the gateway. It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
If an I<invnum> is specified, this payment (if successful) is applied to the
specified invoice. If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
I<quiet> can be set true to surpress email decline notices.
if set, will override the value from the customer record.
I<description> is a free-text field passed to the gateway. It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
If an I<invnum> is specified, this payment (if successful) is applied to the
specified invoice. If you don't specify an I<invnum> you might want to
sub _bop_defaults {
my ($self, $options) = @_;
- $options->{description} ||= 'Internet services';
+ unless ( $options->{'description'} ) {
+ if ( $conf->exists('business-onlinepayment-description') ) {
+ my $dtempl = $conf->config('business-onlinepayment-description');
+
+ my $agent = $self->agent->agent;
+ #$pkgs... not here
+ $options->{'description'} = eval qq("$dtempl");
+ } else {
+ $options->{'description'} = 'Internet services';
+ }
+ }
+
$options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
$options->{invnum} ||= '';
$options->{payname} = $self->payname unless exists( $options->{payname} );
'payinfo' => $options{payinfo},
'paydate' => $paydate,
'recurring_billing' => $content{recurring_billing},
+ 'pkgnum' => $options{'pkgnum'},
'status' => 'new',
'gatewaynum' => $payment_gateway->gatewaynum || '',
'session_id' => $options{session_id} || '',
#'payinfo' => $payinfo,
'paybatch' => $paybatch,
'paydate' => $cust_pay_pending->paydate,
+ 'pkgnum' => $cust_pay_pending->pkgnum,
} );
#doesn't hurt to know, even though the dup check is in cust_pay_pending now
$cust_pay->payunique( $options{payunique} )
} else {
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ if ( $options{'apply'} ) {
+ my $apply_error = $self->apply_payments_and_credits;
+ if ( $apply_error ) {
+ warn "WARNING: error applying payment: $apply_error\n";
+ #but we still should return no error cause the payment otherwise went
+ #through...
+ }
+ }
+
return ''; #no error
}
$template->compile()
or return "($perror) can't compile template: $Text::Template::ERROR";
- my $templ_hash = { error => $transaction->error_message };
+ my $templ_hash = {
+ 'company_name' =>
+ scalar( $conf->config('company_name', $self->agentnum ) ),
+ 'company_address' =>
+ join("\n", $conf->config('company_address', $self->agentnum ) ),
+ 'error' => $transaction->error_message,
+ };
my $error = send_email(
'from' => $conf->config('invoice_from', $self->agentnum ),
) {
warn " attempting void\n" if $DEBUG > 1;
my $void = new Business::OnlinePayment( $processor, @bop_options );
+ if ( $void->can('info') ) {
+ if ( $cust_pay->payby eq 'CARD'
+ && $void->info('CC_void_requires_card') )
+ {
+ $content{'card_number'} = $cust_pay->payinfo;
+ } elsif ( $cust_pay->payby eq 'CHEK'
+ && $void->info('ECHECK_void_requires_account') )
+ {
+ ( $content{'account_number'}, $content{'routing_code'} ) =
+ split('@', $cust_pay->payinfo);
+ $content{'name'} = $self->get('first'). ' '. $self->get('last');
+ }
+ }
$void->content( 'action' => 'void', %content );
$void->submit();
if ( $void->is_success ) {
'';
}
-=item apply_payments_and_credits
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
Applies unapplied payments and credits.
In most cases, this new method should be used in place of sequential
apply_payments and apply_credits methods.
+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 = shift;
+ my( $self, %options ) = @_;
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
$self->select_for_update; #mutex
foreach my $cust_bill ( $self->open_cust_bill ) {
- my $error = $cust_bill->apply_payments_and_credits;
+ my $error = $cust_bill->apply_payments_and_credits(%options);
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return "Error applying: $error";
@invoices = sort { $b->_date <=> $a->_date } @invoices
if defined($opt{'order'}) && $opt{'order'} eq 'newest';
+ if ( $conf->exists('pkg-balances') ) {
+ # limit @credits to those w/ a pkgnum grepped from $self
+ my %pkgnums = ();
+ foreach my $i (@invoices) {
+ foreach my $li ( $i->cust_bill_pkg ) {
+ $pkgnums{$li->pkgnum} = 1;
+ }
+ }
+ @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+ }
+
my $credit;
+
foreach my $cust_bill ( @invoices ) {
- my $amount;
if ( !defined($credit) || $credit->credited == 0) {
$credit = pop @credits or last;
}
- if ($cust_bill->owed >= $credit->credited) {
- $amount=$credit->credited;
- }else{
- $amount=$cust_bill->owed;
+ my $owed;
+ if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
+ $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
+ } else {
+ $owed = $cust_bill->owed;
+ }
+ unless ( $owed > 0 ) {
+ push @credits, $credit;
+ next;
}
+
+ my $amount = min( $credit->credited, $owed );
my $cust_credit_bill = new FS::cust_credit_bill ( {
'crednum' => $credit->crednum,
'invnum' => $cust_bill->invnum,
'amount' => $amount,
} );
+ $cust_credit_bill->pkgnum( $credit->pkgnum )
+ if $conf->exists('pkg-balances') && $credit->pkgnum;
my $error = $cust_credit_bill->insert;
if ( $error ) {
$dbh->rollback or die $dbh->errstr if $oldAutoCommit;
die $error;
}
- redo if ($cust_bill->owed > 0);
+ redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
}
return $total_unapplied_credits;
}
-=item apply_payments
+=item apply_payments [ OPTION => VALUE ... ]
Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
to outstanding invoice balances in chronological order.
#and returns the value of any remaining unapplied payments.
+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.
+
Dies if there is an error.
=cut
sub apply_payments {
- my $self = shift;
+ my( $self, %options ) = @_;
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
grep { $_->owed > 0 }
$self->cust_bill;
+ if ( $conf->exists('pkg-balances') ) {
+ # limit @payments to those w/ a pkgnum grepped from $self
+ my %pkgnums = ();
+ foreach my $i (@invoices) {
+ foreach my $li ( $i->cust_bill_pkg ) {
+ $pkgnums{$li->pkgnum} = 1;
+ }
+ }
+ @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+ }
+
my $payment;
foreach my $cust_bill ( @invoices ) {
- my $amount;
if ( !defined($payment) || $payment->unapplied == 0 ) {
$payment = pop @payments or last;
}
- if ( $cust_bill->owed >= $payment->unapplied ) {
- $amount = $payment->unapplied;
+ my $owed;
+ if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
+ $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
} else {
- $amount = $cust_bill->owed;
+ $owed = $cust_bill->owed;
+ }
+ unless ( $owed > 0 ) {
+ push @payments, $payment;
+ next;
}
+ my $amount = min( $payment->unapplied, $owed );
+
my $cust_bill_pay = new FS::cust_bill_pay ( {
'paynum' => $payment->paynum,
'invnum' => $cust_bill->invnum,
'amount' => $amount,
} );
- my $error = $cust_bill_pay->insert;
+ $cust_bill_pay->pkgnum( $payment->pkgnum )
+ if $conf->exists('pkg-balances') && $payment->pkgnum;
+ my $error = $cust_bill_pay->insert(%options);
if ( $error ) {
$dbh->rollback or die $dbh->errstr if $oldAutoCommit;
die $error;
}
- redo if ( $cust_bill->owed > 0);
+ redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
}
}
+=item total_owed_pkgnum PKGNUM
+
+Returns the total owed on all invoices for this customer's specific package
+when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
+
+=cut
+
+sub total_owed_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
+}
+
+=item total_owed_date_pkgnum TIME PKGNUM
+
+Returns the total owed for this customer's specific package when using
+experimental package balances on all invoices with date earlier than
+TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
+see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=cut
+
+sub total_owed_date_pkgnum {
+ my( $self, $time, $pkgnum ) = @_;
+
+ my $total_bill = 0;
+ foreach my $cust_bill (
+ grep { $_->_date <= $time }
+ qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+ ) {
+ $total_bill += $cust_bill->owed_pkgnum($pkgnum);
+ }
+ sprintf( "%.2f", $total_bill );
+
+}
+
=item total_paid
Returns the total amount of all payments.
sprintf( "%.2f", $total_credit );
}
+=item total_unapplied_credits_pkgnum PKGNUM
+
+Returns the total outstanding credit (see L<FS::cust_credit>) for this
+customer. See L<FS::cust_credit/credited>.
+
+=cut
+
+sub total_unapplied_credits_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ my $total_credit = 0;
+ $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
+ sprintf( "%.2f", $total_credit );
+}
+
+
=item total_unapplied_payments
Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
sprintf( "%.2f", $total_unapplied );
}
+=item total_unapplied_payments_pkgnum PKGNUM
+
+Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
+specific package when using experimental package balances. See
+L<FS::cust_pay/unapplied>.
+
+=cut
+
+sub total_unapplied_payments_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ my $total_unapplied = 0;
+ $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
+ sprintf( "%.2f", $total_unapplied );
+}
+
+
=item total_unapplied_refunds
Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
);
}
+=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns the balance for this customer, only considering invoices with date
+earlier than START_TIME, and optionally not later than END_TIME
+(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
+
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">). Also see L<Time::Local> and
+L<Date::Parse> for conversion functions. The empty string can be passed
+to disable that time constraint completely.
+
+Available options are:
+
+=over 4
+
+=item unapplied_date
+
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+
+=back
+
+=cut
+
+sub balance_date_range {
+ my $self = shift;
+ my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
+ ') FROM cust_main WHERE custnum='. $self->custnum;
+ sprintf( "%.2f", $self->scalar_sql($sql) );
+}
+
+=item balance_pkgnum PKGNUM
+
+Returns the balance for this customer's specific package when using
+experimental package balances (total_owed plus total_unrefunded, minus
+total_unapplied_credits minus total_unapplied_payments)
+
+=cut
+
+sub balance_pkgnum {
+ my( $self, $pkgnum ) = @_;
+
+ sprintf( "%.2f",
+ $self->total_owed_pkgnum($pkgnum)
+# n/a - refunds aren't part of pkg-balances since they don't apply to invoices
+# + $self->total_unapplied_refunds_pkgnum($pkgnum)
+ - $self->total_unapplied_credits_pkgnum($pkgnum)
+ - $self->total_unapplied_payments_pkgnum($pkgnum)
+ );
+}
+
=item in_transit_payments
Returns the total of requests for payments for this customer pending in
join(', ', $self->invoicing_list_emailonly);
}
+=item referral_custnum_cust_main
+
+Returns the customer who referred this customer (or the empty string, if
+this customer was not referred).
+
+Note the difference with referral_cust_main method: This method,
+referral_custnum_cust_main returns the single customer (if any) who referred
+this customer, while referral_cust_main returns an array of customers referred
+BY this customer.
+
+=cut
+
+sub referral_custnum_cust_main {
+ my $self = shift;
+ return '' unless $self->referral_custnum;
+ qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
+}
+
=item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
Returns an array of customers referred by this customer (referral_custnum set
customers referred by customers referred by this customer and so on, inclusive.
The default behavior is DEPTH 1 (no recursion).
+Note the difference with referral_custnum_cust_main method: This method,
+referral_cust_main, returns an array of customers referred BY this customer,
+while referral_custnum_cust_main returns the single customer (if any) who
+referred this customer.
+
=cut
sub referral_cust_main {
#vendor taxation
'taxproduct' => 2, #part_pkg_taxproduct
'override' => {}, #XXX describe
+
+ #will be filled in with the new object
+ 'cust_pkg_ref' => \$cust_pkg,
+
+ #generate an invoice immediately
+ 'bill_now' => 0,
+ 'invoice_terms' => '', #with these terms
}
);
my ( $pkg, $comment, $additional );
my ( $setuptax, $taxclass ); #internal taxes
my ( $taxproduct, $override ); #vendor (CCH) taxes
+ my $cust_pkg_ref = '';
+ my ( $bill_now, $invoice_terms ) = ( 0, '' );
if ( ref( $_[0] ) ) {
$amount = $_[0]->{amount};
$quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
$additional = $_[0]->{additional} || [];
$taxproduct = $_[0]->{taxproductnum};
$override = { '' => $_[0]->{tax_override} };
+ $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
+ $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
+ $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
} else {
$amount = shift;
$quantity = 1;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return $error;
+ } elsif ( $cust_pkg_ref ) {
+ ${$cust_pkg_ref} = $cust_pkg;
+ }
+
+ if ( $bill_now ) {
+ my $error = $self->bill( 'invoice_terms' => $invoice_terms,
+ 'pkg_list' => [ $cust_pkg ],
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
}
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
- '';
+ return '';
}
sub cust_bill {
my $self = shift;
+ map { $_ } #return $self->num_cust_bill unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch('cust_bill', { 'custnum' => $self->custnum, } )
}
}
+=item cust_statements
+
+Returns all the statements (see L<FS::cust_statement>) for this customer.
+
+=cut
+
+sub cust_statement {
+ my $self = shift;
+ map { $_ } #return $self->num_cust_statement unless wantarray;
+ sort { $a->_date <=> $b->_date }
+ qsearch('cust_statement', { 'custnum' => $self->custnum, } )
+}
+
=item cust_credit
Returns all the credits (see L<FS::cust_credit>) for this customer.
sub cust_credit {
my $self = shift;
+ map { $_ } #return $self->num_cust_credit unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
}
+=item cust_credit_pkgnum
+
+Returns all the credits (see L<FS::cust_credit>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_credit_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
+ sort { $a->_date <=> $b->_date }
+ qsearch( 'cust_credit', { 'custnum' => $self->custnum,
+ 'pkgnum' => $pkgnum,
+ }
+ );
+}
+
=item cust_pay
Returns all the payments (see L<FS::cust_pay>) for this customer.
sub cust_pay {
my $self = shift;
+ return $self->num_cust_pay unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
}
+=item num_cust_pay
+
+Returns the number of payments (see L<FS::cust_pay>) for this customer. Also
+called automatically when the cust_pay method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay {
+ my $self = shift;
+ my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute($self->custnum) or die $sth->errstr;
+ $sth->fetchrow_arrayref->[0];
+}
+
+=item cust_pay_pkgnum
+
+Returns all the payments (see L<FS::cust_pay>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_pay_pkgnum {
+ my( $self, $pkgnum ) = @_;
+ map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
+ sort { $a->_date <=> $b->_date }
+ qsearch( 'cust_pay', { 'custnum' => $self->custnum,
+ 'pkgnum' => $pkgnum,
+ }
+ );
+}
+
=item cust_pay_void
Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
sub cust_pay_void {
my $self = shift;
+ map { $_ } #return $self->num_cust_pay_void unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
}
sub cust_pay_batch {
my $self = shift;
+ map { $_ } #return $self->num_cust_pay_batch unless wantarray;
sort { $a->paybatchnum <=> $b->paybatchnum }
qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
}
sub cust_refund {
my $self = shift;
+ map { $_ } #return $self->num_cust_refund unless wantarray;
sort { $a->_date <=> $b->_date }
qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
}
? 'ship_'
: '';
- my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+ my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
if $self->country eq 'US';
+ $zip ||= '';
+ $plus4 ||= '';
#CCH specific location stuff
my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
}
+# Return a list of latitude/longitude for one of the services (if any)
+sub service_coordinates {
+ my $self = shift;
+
+ my @svc_X =
+ grep { $_->latitude && $_->longitude }
+ map { $_->svc_x }
+ map { $_->cust_svc }
+ $self->ncancelled_pkgs;
+
+ scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
+}
+
=back
=head1 CLASS METHODS
}
-=item search_sql HASHREF
+=item search HASHREF
(Class method)
-Returns a qsearch hash expression to search for parameters specified in HREF.
-Valid parameters are
+Returns a qsearch hash expression to search for parameters specified in
+HASHREF. Valid parameters are
=over 4
listref
+=item paydate_year
+
+=item paydate_month
+
=item current_balance
listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
=cut
-sub search_sql {
+sub search {
my ($class, $params) = @_;
my $dbh = dbh;
# payby
###
+ if ( $params->{'payby'} ) {
+
+ my @payby = ref( $params->{'payby'} )
+ ? @{ $params->{'payby'} }
+ : ( $params->{'payby'} );
+
+ @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+
+ push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
+ if @payby;
+
+ }
+
my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
if ( @payby ) {
push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
}
+ ###
+ # paydate_year / paydate_month
+ ###
+
+ if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
+ my $year = $1;
+ $params->{'paydate_month'} =~ /^(\d\d?)$/
+ or die "paydate_year without paydate_month?";
+ my $month = $1;
+
+ push @where,
+ 'paydate IS NOT NULL',
+ "paydate != ''",
+ "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
+;
+ }
+
+ ###
+ # invoice terms
+ ###
+
+ if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
+ my $terms = $1;
+ if ( $1 eq 'NULL' ) {
+ push @where,
+ "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
+ } else {
+ push @where,
+ "cust_main.invoice_terms IS NOT NULL",
+ "cust_main.invoice_terms = '$1'";
+ }
+ }
+
##
# amounts
##
- #my $balance_sql = $class->balance_sql();
- my $balance_sql = FS::cust_main->balance_sql();
+ if ( $params->{'current_balance'} ) {
+
+ #my $balance_sql = $class->balance_sql();
+ my $balance_sql = FS::cust_main->balance_sql();
+
+ my @current_balance =
+ ref( $params->{'current_balance'} )
+ ? @{ $params->{'current_balance'} }
+ : ( $params->{'current_balance'} );
- push @where, map { s/current_balance/$balance_sql/; $_ }
- @{ $params->{'current_balance'} };
+ push @where, map { s/current_balance/$balance_sql/; $_ }
+ @current_balance;
+
+ }
##
# custbatch
}
-=item email_search_sql HASHREF
+=item email_search_result HASHREF
(Class method)
Emails a notice to the specified customers.
-Valid parameters are those of the L<search_sql> method, plus the following:
+Valid parameters are those of the L<search> method, plus the following:
=over 4
=cut
-sub email_search_sql {
+sub email_search_result {
my($class, $params) = @_;
my $from = delete $params->{from};
my $job = delete $params->{'job'};
- my $sql_query = $class->search_sql($params);
+ $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
+ unless ref($params->{'payby'});
+
+ my $sql_query = $class->search($params);
my $count_query = delete($sql_query->{'count_query'});
my $count_sth = dbh->prepare($count_query)
use Storable qw(thaw);
use Data::Dumper;
use MIME::Base64;
-sub process_email_search_sql {
+sub process_email_search_result {
my $job = shift;
#warn "$me process_re_X $method for job $job\n" if $DEBUG;
$param->{'job'} = $job;
- my $error = FS::cust_main->email_search_sql( $param );
+ $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
+ unless ref($param->{'payby'});
+
+ my $error = FS::cust_main->email_search_result( $param );
die $error if $error;
}
=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records. Currently, I<first>, I<last> and/or I<company> may be specified (the
-appropriate ship_ field is also searched).
+records. Currently, I<first>, I<last>, I<company> and/or I<address1> may be
+specified (the appropriate ship_ field is also searched).
Additional options are the same as FS::Record::qsearch
}
if ( $search =~ /^\s*(\d+)\s*$/
- || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
- && $search =~ /^\s*(\w\w?\d+)\s*$/
- )
- )
+ || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+ && $search =~ /^\s*(\w\w?\d+)\s*$/
+ )
+ || ( $conf->exists('address1-search' )
+ && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
+ )
+ )
{
my $num = $1;
- if ( $num <= 2147483647 ) { #need a bigint custnum? wow.
+ if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
push @cust_main, qsearch( {
'table' => 'cust_main',
'hashref' => { 'custnum' => $num, %options },
'extra_sql' => " AND $agentnums_sql", #agent virtualization
} );
+ if ( $conf->exists('address1-search') ) {
+ my $len = length($num);
+ $num = lc($num);
+ foreach my $prefix ( '', 'ship_' ) {
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %options, },
+ 'extra_sql' =>
+ ( keys(%options) ? ' AND ' : ' WHERE ' ).
+ " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
+ " AND $agentnums_sql",
+ } );
+ }
+ }
+
} elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
my($company, $last, $first) = ( $1, $2, $3 );
# "Company (Last, First)"
#this is probably something a browser remembered,
- #so just do an exact search
+ #so just do an exact search (but case-insensitive, so USPS standardization
+ #doesn't throw a wrench in the works)
foreach my $prefix ( '', 'ship_' ) {
push @cust_main, qsearch( {
'table' => 'cust_main',
- 'hashref' => { $prefix.'first' => $first,
- $prefix.'last' => $last,
- $prefix.'company' => $company,
- %options,
- },
- 'extra_sql' => " AND $agentnums_sql",
+ 'hashref' => { %options },
+ 'extra_sql' =>
+ ( keys(%options) ? ' AND ' : ' WHERE ' ).
+ join(' AND ',
+ " LOWER(${prefix}first) = ". dbh->quote(lc($first)),
+ " LOWER(${prefix}last) = ". dbh->quote(lc($last)),
+ " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
+ $agentnums_sql,
+ ),
} );
}
#exact
my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
- $sql .= " ( LOWER(last) = $q_value
- OR LOWER(company) = $q_value
- OR LOWER(ship_last) = $q_value
- OR LOWER(ship_company) = $q_value
- )";
+ $sql .= " ( LOWER(last) = $q_value
+ OR LOWER(company) = $q_value
+ OR LOWER(ship_last) = $q_value
+ OR LOWER(ship_company) = $q_value
+ ";
+ $sql .= " OR LOWER(address1) = $q_value
+ OR LOWER(ship_address1) = $q_value
+ "
+ if $conf->exists('address1-search');
+ $sql .= " )";
push @cust_main, qsearch( {
'table' => 'cust_main',
#getting complaints searches are not returning enough
unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
- #still some false laziness w/search_sql (was search/cust_main.cgi)
+ #still some false laziness w/search (was search/cust_main.cgi)
#substring
;
}
+ if ( $conf->exists('address1-search') ) {
+ push @hashrefs,
+ { 'address1' => { op=>'ILIKE', value=>"%$value%" }, },
+ { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
+ ;
+ }
+
foreach my $hashref ( @hashrefs ) {
push @cust_main, qsearch( {
push @cust_main,
FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
}
+ if ( $conf->exists('address1-search') ) {
+ push @cust_main,
+ FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
+ }
}
=cut
-use vars qw(@fuzzyfields);
-@fuzzyfields = ( 'last', 'first', 'company' );
-
sub check_and_rebuild_fuzzyfiles {
my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
\@array;
}
-=item append_fuzzyfiles LASTNAME COMPANY
+=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
=cut
my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
- foreach my $field (qw( first last company )) {
+ foreach my $field (@fuzzyfields) {
my $value = shift;
if ( $value ) {