From 0376f47e1ec2ec9b8702a0e6c5146af9c66beb5e Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 30 Jun 2009 12:28:38 +0000 Subject: [PATCH] one-time charge "hold for later" / any package future start date, RT#5347 --- FS/FS/Cron/bill.pm | 193 +++++++++++++++---------- FS/FS/Schema.pm | 5 +- FS/FS/cust_main.pm | 51 +++++-- FS/FS/cust_pkg.pm | 5 + httemplate/edit/REAL_cust_pkg.cgi | 1 + httemplate/edit/process/REAL_cust_pkg.cgi | 1 + httemplate/edit/process/quick-charge.cgi | 4 + httemplate/edit/quick-charge.html | 38 +++++ httemplate/view/cust_main/packages/status.html | 43 ++++-- 9 files changed, 237 insertions(+), 104 deletions(-) diff --git a/FS/FS/Cron/bill.pm b/FS/FS/Cron/bill.pm index 29f5f36c9..61b47355a 100644 --- a/FS/FS/Cron/bill.pm +++ b/FS/FS/Cron/bill.pm @@ -13,10 +13,16 @@ use FS::part_event; use FS::part_event_condition; @ISA = qw( Exporter ); -@EXPORT_OK = qw ( bill ); +@EXPORT_OK = qw ( bill bill_where ); -sub bill { +#freeside-daily %opt: +# -s: re-charge setup fees +# -v: enable debugging +# -l: debugging level +# -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing. +# -r: Multi-process mode dry run option +sub bill { my %opt = @_; my $check_freq = $opt{'check_freq'} || '1d'; @@ -24,91 +30,24 @@ sub bill { my $debug = 0; $debug = 1 if $opt{'v'}; $debug = $opt{'l'} if $opt{'l'}; - $FS::cust_main::DEBUG = $debug; #$FS::cust_event::DEBUG = $opt{'l'} if $opt{'l'}; - my @search = (); - - push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable? - - push @search, "cust_main.payby = '". $opt{'p'}. "'" - if $opt{'p'}; - push @search, "cust_main.agentnum = ". $opt{'a'} - if $opt{'a'}; - - if ( @ARGV ) { - push @search, "( ". - join(' OR ', map "cust_main.custnum = $_", @ARGV ). - " )"; - } - - ### - # generate where_pkg/where_event search clause - ### - #we're at now now (and later). - my($time)= $opt{'d'} ? str2time($opt{'d'}) : $^T; - $time += $opt{'y'} * 86400 if $opt{'y'}; - - my $invoice_time = $opt{'n'} ? $^T : $time; + $opt{'time'} = $opt{'d'} ? str2time($opt{'d'}) : $^T; + $opt{'time'} += $opt{'y'} * 86400 if $opt{'y'}; - # select * from cust_main where - my $where_pkg = <<"END"; - 0 < ( select count(*) from cust_pkg - where cust_main.custnum = cust_pkg.custnum - and ( cancel is null or cancel = 0 ) - and ( setup is null or setup = 0 - or bill is null or bill <= $time - or ( expire is not null and expire <= $^T ) - or ( adjourn is not null and adjourn <= $^T ) - ) - ) -END - - my $where_event = join(' OR ', map { - my $eventtable = $_; - - my $join = FS::part_event_condition->join_conditions_sql( $eventtable ); - my $where = FS::part_event_condition->where_conditions_sql( $eventtable, - 'time'=>$time, - ); - - my $are_part_event = - "0 < ( SELECT COUNT(*) FROM part_event $join - WHERE check_freq = '$check_freq' - AND eventtable = '$eventtable' - AND ( disabled = '' OR disabled IS NULL ) - AND $where - ) - "; - - if ( $eventtable eq 'cust_main' ) { - $are_part_event; - } else { - "0 < ( SELECT COUNT(*) FROM $eventtable - WHERE cust_main.custnum = $eventtable.custnum - AND $are_part_event - ) - "; - } - - } FS::part_event->eventtables); - - push @search, "( $where_pkg OR $where_event )"; + $opt{'invoice_time'} = $opt{'n'} ? $^T : $opt{'time'}; ### # get a list of custnums ### - warn "searching for customers:\n". join("\n", @search). "\n" - if $opt{'v'} || $opt{'l'}; - my $cursor_dbh = dbh->clone; $cursor_dbh->do( "DECLARE cron_bill_cursor CURSOR FOR ". - " SELECT custnum FROM cust_main WHERE ". join(' AND ', @search) + " SELECT custnum FROM cust_main WHERE ". bill_where( %opt ) ) or die $cursor_dbh->errstr; while ( 1 ) { @@ -129,8 +68,8 @@ END foreach my $custnum ( @custnums ) { my %args = ( - 'time' => $time, - 'invoice_time' => $invoice_time, + 'time' => $opt{'time'}, + 'invoice_time' => $opt{'invoice_time'}, 'actual_time' => $^T, #when freeside-bill was started #(not, when using -m, freeside-queued) 'check_freq' => $check_freq, @@ -175,4 +114,108 @@ END } +# freeside-daily %opt: +# -d: Pretend it's 'date'. Date is in any format Date::Parse is happy with, +# but be careful. +# +# -y: In addition to -d, which specifies an absolute date, the -y switch +# specifies an offset, in days. For example, "-y 15" would increment the +# "pretend date" 15 days from whatever was specified by the -d switch +# (or now, if no -d switch was given). +# +# -n: When used with "-d" and/or "-y", specifies that invoices should be dated +# with today's date, irregardless of the pretend date used to pre-generate +# the invoices. +# +# -p: Only process customers with the specified payby (I, I, I, I, I, I, I) +# +# -a: Only process customers with the specified agentnum +# +# -v: enable debugging +# +# -l: debugging level + +sub bill_where { + my( %opt ) = @_; + + my $time = $opt{'time'}; + my $invoice_time = $opt{'invoice_time'}; + + my $check_freq = $opt{'check_freq'} || '1d'; + + my @search = (); + + push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable? + + push @search, "cust_main.payby = '". $opt{'p'}. "'" + if $opt{'p'}; + push @search, "cust_main.agentnum = ". $opt{'a'} + if $opt{'a'}; + + if ( @ARGV ) { + push @search, "( ". + join(' OR ', map "cust_main.custnum = $_", @ARGV ). + " )"; + } + + ### + # generate where_pkg/where_event search clause + ### + + # select * from cust_main where + my $where_pkg = <<"END"; + EXISTS( + SELECT 1 FROM cust_pkg + WHERE cust_main.custnum = cust_pkg.custnum + AND ( cancel IS NULL OR cancel = 0 ) + AND ( ( ( setup IS NULL OR setup = 0 ) + AND ( start_date IS NULL OR start_date = 0 + OR ( start_date IS NOT NULL AND start_date <= $^T ) + ) + ) + OR bill IS NULL OR bill <= $time + OR ( expire IS NOT NULL AND expire <= $^T ) + OR ( adjourn IS NOT NULL AND adjourn <= $^T ) + ) + ) +END + + my $where_event = join(' OR ', map { + my $eventtable = $_; + + my $join = FS::part_event_condition->join_conditions_sql( $eventtable ); + my $where = FS::part_event_condition->where_conditions_sql( $eventtable, + 'time'=>$time, + ); + + my $are_part_event = + "EXISTS ( SELECT 1 FROM part_event $join + WHERE check_freq = '$check_freq' + AND eventtable = '$eventtable' + AND ( disabled = '' OR disabled IS NULL ) + AND $where + ) + "; + + if ( $eventtable eq 'cust_main' ) { + $are_part_event; + } else { + "EXISTS ( SELECT 1 FROM $eventtable + WHERE cust_main.custnum = $eventtable.custnum + AND $are_part_event + ) + "; + } + + } FS::part_event->eventtables); + + push @search, "( $where_pkg OR $where_event )"; + + warn "searching for customers:\n". join("\n", @search). "\n" + if $opt{'v'} || $opt{'l'}; + + join(' AND ', @search); + +} + 1; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 4351f2876..3c203b9d1 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1085,6 +1085,7 @@ sub tables_hashref { 'pkgpart', 'int', '', '', '', '', 'locationnum', 'int', 'NULL', '', '', '', 'otaker', 'varchar', '', 32, '', '', + 'start_date', @date_type, '', '', 'setup', @date_type, '', '', 'bill', @date_type, '', '', 'last_bill', @date_type, '', '', @@ -1102,8 +1103,8 @@ sub tables_hashref { 'primary_key' => 'pkgnum', 'unique' => [], 'index' => [ ['custnum'], ['pkgpart'], [ 'locationnum' ], - ['setup'], ['last_bill'], ['bill'], ['susp'], ['adjourn'], - ['expire'], ['cancel'], + [ 'start_date' ], ['setup'], ['last_bill'], ['bill'], + ['susp'], ['adjourn'], ['expire'], ['cancel'], ['change_date'], ], }, diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index e5f289ca3..5768bebf9 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -9,6 +9,7 @@ use Safe; use Carp; use Exporter; use Scalar::Util qw( blessed ); +use List::Util qw( min ); use Time::Local qw(timelocal); use Data::Dumper; use Tie::IxHash; @@ -2056,6 +2057,18 @@ sub unsuspended_pkgs { grep { ! $_->susp } $self->ncancelled_pkgs; } +=item next_bill_date + +Returns the next date this customer will be billed, as a UNIX timestamp, or +undef if no active package has a next bill date. + +=cut + +sub next_bill_date { + my $self = shift; + min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs ); +} + =item num_cancelled_pkgs Returns the number of cancelled packages (see L) for this @@ -2760,14 +2773,19 @@ sub _make_lines { my $setup = 0; my $unitsetup = 0; - if ( ! $cust_pkg->setup && - ( - ( $conf->exists('disable_setup_suspended_pkgs') && - ! $cust_pkg->getfield('susp') - ) || ! $conf->exists('disable_setup_suspended_pkgs') - ) - || $options{'resetup'} - ) { + if ( $options{'resetup'} + || ( ! $cust_pkg->setup + && ( ! $cust_pkg->start_date + || $cust_pkg->start_date <= $time + ) + && ( ! $conf->exists('disable_setup_suspended_pkgs') + || ( $conf->exists('disable_setup_suspended_pkgs') && + ! $cust_pkg->getfield('susp') + ) + ) + ) + ) + { warn " bill setup\n" if $DEBUG > 1; $lineitems++; @@ -2783,6 +2801,9 @@ sub _make_lines { #do need it, but it won't get written to the db #|| $cust_pkg->pkgpart != $real_pkgpart; + $cust_pkg->setfield('start_date', '') + if $cust_pkg->start_date; + } ### @@ -6727,12 +6748,14 @@ the error, otherwise returns false. sub charge { my $self = shift; - my ( $amount, $quantity, $pkg, $comment, $classnum, $additional ); + my ( $amount, $quantity, $start_date, $classnum ); + my ( $pkg, $comment, $additional ); my ( $setuptax, $taxclass ); #internal taxes my ( $taxproduct, $override ); #vendor (CCH) taxes if ( ref( $_[0] ) ) { $amount = $_[0]->{amount}; $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; + $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : ''; $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge'; $comment = exists($_[0]->{comment}) ? $_[0]->{comment} : '$'. sprintf("%.2f",$amount); @@ -6742,9 +6765,10 @@ sub charge { $additional = $_[0]->{additional}; $taxproduct = $_[0]->{taxproductnum}; $override = { '' => $_[0]->{tax_override} }; - }else{ + } else { $amount = shift; $quantity = 1; + $start_date = ''; $pkg = @_ ? shift : 'One-time charge'; $comment = @_ ? shift : '$'. sprintf("%.2f",$amount); $setuptax = ''; @@ -6802,9 +6826,10 @@ sub charge { } my $cust_pkg = new FS::cust_pkg ( { - 'custnum' => $self->custnum, - 'pkgpart' => $pkgpart, - 'quantity' => $quantity, + 'custnum' => $self->custnum, + 'pkgpart' => $pkgpart, + 'quantity' => $quantity, + 'start_date' => $start_date, } ); $error = $cust_pkg->insert; diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 881e005de..d2f0690eb 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -122,6 +122,10 @@ Billing item definition (see L) Optional link to package location (see L) +=item start_date + +date + =item setup date @@ -479,6 +483,7 @@ sub check { || $self->ut_foreign_key('custnum', 'cust_main', 'custnum') || $self->ut_numbern('pkgpart') || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum') + || $self->ut_numbern('start_date') || $self->ut_numbern('setup') || $self->ut_numbern('bill') || $self->ut_numbern('susp') diff --git a/httemplate/edit/REAL_cust_pkg.cgi b/httemplate/edit/REAL_cust_pkg.cgi index a707aca8d..5752c8dd8 100755 --- a/httemplate/edit/REAL_cust_pkg.cgi +++ b/httemplate/edit/REAL_cust_pkg.cgi @@ -46,6 +46,7 @@ <% $cust_pkg->otaker %> + <& .row_edit, cust_pkg=>$cust_pkg, column=>'start_date', label=>'Start' &> <& .row_edit, cust_pkg=>$cust_pkg, column=>'setup', label=>'Setup' &> <& .row_edit, cust_pkg=>$cust_pkg, column=>'last_bill', label=>$last_bill_or_renewed &> <& .row_edit, cust_pkg=>$cust_pkg, column=>'bill', label=>$next_bill_or_prepaid_until &> diff --git a/httemplate/edit/process/REAL_cust_pkg.cgi b/httemplate/edit/process/REAL_cust_pkg.cgi index c99ddc288..d4ba976c4 100755 --- a/httemplate/edit/process/REAL_cust_pkg.cgi +++ b/httemplate/edit/process/REAL_cust_pkg.cgi @@ -19,6 +19,7 @@ die "access denied" my $pkgnum = $cgi->param('pkgnum') or die; my $old = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum}); my %hash = $old->hash; +$hash{'start_date'} = $cgi->param('start_date') ? str2time($cgi->param('start_date')) : ''; $hash{'setup'} = $cgi->param('setup') ? str2time($cgi->param('setup')) : ''; $hash{'bill'} = $cgi->param('bill') ? str2time($cgi->param('bill')) : ''; $hash{'last_bill'} = diff --git a/httemplate/edit/process/quick-charge.cgi b/httemplate/edit/process/quick-charge.cgi index 470cd4b5b..8f0e42471 100644 --- a/httemplate/edit/process/quick-charge.cgi +++ b/httemplate/edit/process/quick-charge.cgi @@ -55,6 +55,10 @@ unless ( $error ) { $error ||= $cust_main->charge( { 'amount' => $amount, 'quantity' => $quantity, + 'start_date' => ( scalar($cgi->param('start_date')) + ? str2time($cgi->param('start_date')) + : '' + ), 'pkg' => scalar($cgi->param('pkg')), 'setuptax' => scalar($cgi->param('setuptax')), 'taxclass' => scalar($cgi->param('taxclass')), diff --git a/httemplate/edit/quick-charge.html b/httemplate/edit/quick-charge.html index 8aca34a0d..ad26bff66 100644 --- a/httemplate/edit/quick-charge.html +++ b/httemplate/edit/quick-charge.html @@ -3,6 +3,11 @@ ) %> + + + + + <% include('/elements/error.html') %> + Tax exempt @@ -179,6 +211,12 @@ my $conf = new FS::Conf; $cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum'; my $custnum = $1; +my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); #XXX agent-virt + +my $format = "%m/%d/%Y %T %z (%Z)"; #false laziness w/REAL_cust_pkg.cgi? +my $start_date = $cust_main->next_bill_date; +warn "*** $start_date"; +$start_date = $start_date ? time2str($format, $start_date) : ''; my $amount = ''; if ( $cgi->param('amount') =~ /^\s*\$?\s*(\d+(\.\d{1,2})?)\s*$/ ) { diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html index 141ed156d..6daff5031 100644 --- a/httemplate/view/cust_main/packages/status.html +++ b/httemplate/view/cust_main/packages/status.html @@ -10,18 +10,18 @@ <% pkg_status_row_colspan( ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '', - 'align' => 'right', 'color' => 'ff0000', 'size' => '-2', + 'align'=>'right', 'color'=>'ff0000', 'size'=>'-2', 'colspan'=>$colspan, ) %> % unless ( $cust_pkg->get('setup') ) { - <% pkg_status_row_colspan('Never billed') %> + <% pkg_status_row_colspan('Never billed', '', 'colspan'=>$colspan, ) %> % } else { <% pkg_status_row( $cust_pkg, 'Setup', 'setup', %opt ) %> - <% pkg_status_row_changed( $cust_pkg, %opt ) %> + <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %> <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %> <% pkg_status_row_if( $cust_pkg, 'Suspended', 'susp', %opt, curuser=>$curuser ) %> @@ -36,17 +36,17 @@ <% pkg_status_row_colspan( ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '', - 'align' => 'right', 'color' => 'FF9900', 'size' => '-2', + 'align'=>'right', 'color'=>'FF9900', 'size'=>'-2', 'colspan'=>$colspan, ) %> % unless ( $cust_pkg->get('setup') ) { - <% pkg_status_row_colspan('Never billed') %> + <% pkg_status_row_colspan('Never billed', '', 'colspan'=>$colspan ) %> % } else { <% pkg_status_row($cust_pkg, 'Setup', 'setup', %opt ) %> % } - <% pkg_status_row_changed( $cust_pkg, %opt ) %> + <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %> <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %> % # pkg_status_row($cust_pkg, 'Next bill', 'bill', %opt) <% pkg_status_row_if( $cust_pkg, 'Expires', 'expire', %opt, curuser=>$curuser ) %> @@ -70,7 +70,9 @@ % % unless ( $part_pkg->freq ) { - <% pkg_status_row_colspan('Not yet billed (one-time charge)') %> + <% pkg_status_row_colspan('Not yet billed (one-time charge)', '', 'colspan'=>$colspan, ) %> + + <% pkg_status_row_if($cust_pkg, 'Start billing', 'start_date', %opt) %> > @@ -84,7 +86,9 @@ % } else { - <% pkg_status_row_colspan("Not yet billed ($billed_or_prepaid ". myfreq($part_pkg). ')' ) %> + <% pkg_status_row_colspan("Not yet billed ($billed_or_prepaid ". myfreq($part_pkg). ')', '', 'colspan'=>$colspan ) %> + + <% pkg_status_row_if($cust_pkg, 'Start billing', 'start_date', %opt) %> % } % @@ -92,7 +96,7 @@ % % unless ( $part_pkg->freq ) { - <% pkg_status_row_colspan('One-time charge') %> + <% pkg_status_row_colspan('One-time charge', '', 'colspan'=>$colspan, ) %> <% pkg_status_row($cust_pkg, 'Billed', 'setup', %opt) %> @@ -103,7 +107,7 @@ <% pkg_status_row_colspan( 'Overlimit', $billed_or_prepaid. ' '. myfreq($part_pkg), - 'color' => 'FFD000', + 'color'=>'FFD000', 'colspan'=>$colspan, ) %> @@ -111,7 +115,7 @@ <% pkg_status_row_colspan( 'Active', $billed_or_prepaid. ' '. myfreq($part_pkg), - 'color' => '00CC00', + 'color'=>'00CC00', 'colspan'=>$colspan, ) %> % } @@ -127,7 +131,7 @@ % $cust_pkg->set('autosuspend', $autosuspend) if $autosuspend; % } - <% pkg_status_row_changed( $cust_pkg, %opt ) %> + <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %> <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %> <% pkg_status_row_if( $cust_pkg, $next_bill_or_prepaid_until, 'bill', %opt, curuser=>$curuser ) %> <% pkg_status_row_if($cust_pkg, 'Will automatically suspend by', 'autosuspend', %opt) %> @@ -238,21 +242,32 @@ sub pkg_status_row_if { sub pkg_status_row_changed { my( $cust_pkg, %opt ) = @_; + return '' unless $cust_pkg->change_date; - my $html = pkg_status_row( $cust_pkg, 'Package changed', 'change_date', %opt ); + + my $html = + pkg_status_row( $cust_pkg, 'Package changed', 'change_date', %opt ); + my $old = $cust_pkg->old_cust_pkg; if ( $old ) { my $part_pkg = $old->part_pkg; my $label = 'Changed from '. $cust_pkg->change_pkgnum. ': '. $part_pkg->pkg_comment(nopartpkg => 1); - $html .= pkg_status_row_colspan( $label, '', size=>'-1', align=>'right' ); + $html .= pkg_status_row_colspan( $label, '', + 'size' => '-1', + 'align' => 'right', + 'colspan' => $opt{'colspan'}, + ); } + $html; } sub pkg_status_row_colspan { my($title, $addl, %opt) = @_; + my $colspan = $opt{'colspan'}; + my $align = $opt{'align'} ? 'ALIGN="'. $opt{'align'}.'"' : ''; my $color = $opt{'color'} ? 'COLOR="#'.$opt{'color'}.'"' : ''; my $size = $opt{'size'} ? 'SIZE="'. $opt{'size'}. '"' : ''; -- 2.11.0