1 package FS::Cron::bill;
4 use vars qw( @ISA @EXPORT_OK );
7 use DBI 1.33; #The "clone" method was added in DBI 1.33.
8 use FS::UID qw( dbh driver_name );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::Misc::DateTime qw( day_end );
14 use FS::part_event_condition;
16 @ISA = qw( Exporter );
17 @EXPORT_OK = qw ( bill bill_where );
20 # -s: re-charge setup fees
21 # -v: enable debugging
23 # -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing.
24 # -r: Multi-process mode dry run option
25 # -g: Don't bill these pkgparts
30 my $check_freq = $opt{'check_freq'} || '1d';
33 $debug = 1 if $opt{'v'};
34 $debug = $opt{'l'} if $opt{'l'};
35 $FS::cust_main::DEBUG = $debug;
36 #$FS::cust_event::DEBUG = $opt{'l'} if $opt{'l'};
38 my $conf = new FS::Conf;
39 if ( $conf->exists('disable_cron_billing') ) {
40 warn "disable_cron_billing set, skipping billing\n" if $debug;
44 #we're at now now (and later).
45 $opt{'time'} = $opt{'d'} ? str2time($opt{'d'}) : $^T;
46 $opt{'time'} += $opt{'y'} * 86400 if $opt{'y'};
48 $opt{'invoice_time'} = $opt{'n'} ? $^T : $opt{'time'};
50 #hashref here doesn't work with -m
51 #my $not_pkgpart = $opt{g} ? { map { $_=>1 } split(/,\s*/, $opt{g}) }
55 # get a list of custnums
58 my $cursor_dbh = dbh->clone;
60 my $select = 'SELECT custnum FROM cust_main WHERE '. bill_where( %opt );
62 unless ( driver_name =~ /^mysql/ ) {
63 $cursor_dbh->do( "DECLARE cron_bill_cursor CURSOR FOR $select" )
64 or die $cursor_dbh->errstr;
69 my $sql = (driver_name =~ /^mysql/)
71 : 'FETCH 100 FROM cron_bill_cursor';
73 my $sth = $cursor_dbh->prepare($sql);
75 $sth->execute or die $sth->errstr;
77 my @custnums = map { $_->[0] } @{ $sth->fetchall_arrayref };
79 last unless scalar(@custnums);
82 # for each custnum, queue or make one customer object and bill
83 # (one at a time, to reduce memory footprint with large #s of customers)
86 foreach my $custnum ( @custnums ) {
89 'time' => $opt{'time'},
90 'invoice_time' => $opt{'invoice_time'},
91 'actual_time' => $^T, #when freeside-bill was started
92 #(not, when using -m, freeside-queued)
93 'check_freq' => $check_freq,
94 'resetup' => ( $opt{'s'} ? $opt{'s'} : 0 ),
95 'not_pkgpart' => $opt{'g'}, #$not_pkgpart,
101 warn "DRY RUN: would add custnum $custnum for queued_bill\n";
104 #avoid queuing another job if there's one still waiting to run
105 next if qsearch( 'queue', { 'job' => 'FS::cust_main::queued_bill',
106 'custnum' => $custnum,
111 #add job to queue that calls bill_and_collect with options
112 my $queue = new FS::queue {
113 'job' => 'FS::cust_main::queued_bill',
115 'priority' => 99, #don't get in the way of provisioning jobs
117 my $error = $queue->insert( 'custnum'=>$custnum, %args );
123 my $cust_main = qsearchs( 'cust_main', { 'custnum' => $custnum } );
124 $cust_main->bill_and_collect( %args, 'debug' => $debug );
130 last if driver_name =~ /^mysql/;
134 $cursor_dbh->commit or die $cursor_dbh->errstr;
138 # freeside-daily %opt:
139 # -d: Pretend it's 'date'. Date is in any format Date::Parse is happy with,
142 # -y: In addition to -d, which specifies an absolute date, the -y switch
143 # specifies an offset, in days. For example, "-y 15" would increment the
144 # "pretend date" 15 days from whatever was specified by the -d switch
145 # (or now, if no -d switch was given).
147 # -n: When used with "-d" and/or "-y", specifies that invoices should be dated
148 # with today's date, irregardless of the pretend date used to pre-generate
151 # -p: Only process customers with the specified payby (I<CARD>, I<DCRD>, I<CHEK>, I<DCHK>, I<BILL>, I<COMP>, I<LECB>)
153 # -a: Only process customers with the specified agentnum
155 # -v: enable debugging
157 # -l: debugging level
161 Internal function. Returns a WHERE clause to select the set of customers who
162 have actionable packages (no setup date, or bill date in the past, or expire
163 or adjourn dates in the past) or events (does a complete where_conditions_sql
171 my $time = $opt{'time'};
172 my $invoice_time = $opt{'invoice_time'};
174 my $check_freq = $opt{'check_freq'} || '1d';
178 push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable?
180 push @search, "cust_main.payby = '". $opt{'p'}. "'"
182 push @search, "cust_main.agentnum IN ( ". $opt{'a'}. " ) "
185 #it would be useful if i recognized $opt{g} / $not_pkgpart...
189 join(' OR ', map "cust_main.custnum = $_", @ARGV ).
194 # generate where_pkg/where_event search clause
197 my $billtime = day_end($time);
199 # select * from cust_main where
200 my $where_pkg = <<"END";
202 SELECT 1 FROM cust_pkg
203 WHERE cust_main.custnum = cust_pkg.custnum
204 AND ( cancel IS NULL OR cancel = 0 )
205 AND ( ( ( setup IS NULL OR setup = 0 )
206 AND ( start_date IS NULL OR start_date = 0
207 OR ( start_date IS NOT NULL AND start_date <= $^T )
210 OR bill IS NULL OR bill <= $billtime
211 OR ( expire IS NOT NULL AND expire <= $^T )
212 OR ( adjourn IS NOT NULL AND adjourn <= $^T )
217 #some false laziness w/cust_main::Billing due_cust_event
218 my $where_event = join(' OR ', map {
221 # joins and where clauses to test event conditions
222 my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
223 my $where = FS::part_event_condition->where_conditions_sql( $eventtable,
226 $where = $where ? "AND $where" : '';
228 # test to return all applicable part_events (defined on this eventtable,
229 # not disabled, check_freq correct, and all event conditions true)
231 "EXISTS ( SELECT 1 FROM part_event $join
232 WHERE check_freq = '$check_freq'
233 AND eventtable = '$eventtable'
234 AND ( disabled = '' OR disabled IS NULL )
239 if ( $eventtable eq 'cust_main' ) {
242 my $cust_join = FS::part_event->eventtables_cust_join->{$eventtable}
244 my $custnum = FS::part_event->eventtables_custnum->{$eventtable};
245 "EXISTS ( SELECT 1 FROM $eventtable $cust_join
246 WHERE cust_main.custnum = $custnum
252 } FS::part_event->eventtables);
254 push @search, "( $where_pkg OR $where_event )";
256 warn "searching for customers:\n". join("\n", @search). "\n"
257 if $opt{'v'} || $opt{'l'};
259 join(' AND ', @search);