add option for multi-process billing to queue additional jobs for the same customer...
[freeside.git] / FS / FS / Cron / bill.pm
1 package FS::Cron::bill;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK );
5 use Exporter;
6 use Date::Parse;
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 );
11 use FS::queue;
12 use FS::cust_main;
13 use FS::part_event;
14 use FS::part_event_condition;
15
16 use FS::Log;
17
18 @ISA = qw( Exporter );
19 @EXPORT_OK = qw ( bill bill_where );
20
21 #freeside-daily %opt:
22 #  -s: re-charge setup fees
23 #  -v: enable debugging
24 #  -l: debugging level
25 #  -m: Multi-process mode uses the job queue for multi-process and/or multi-machine billing.
26 #  -q: Multi-process mode: queue additional job instead of skipping
27 #  -r: Multi-process mode dry run option
28 #  -g: Don't bill these pkgparts
29
30 sub bill {
31   my %opt = @_;
32
33   my $log = FS::Log->new('Cron::bill');
34   $log->info('start');
35
36   my $check_freq = $opt{'check_freq'} || '1d';
37
38   my $debug = 0;
39   $debug = 1 if $opt{'v'};
40   $debug = $opt{'l'} if $opt{'l'};
41   $FS::cust_main::DEBUG = $debug;
42   #$FS::cust_event::DEBUG = $opt{'l'} if $opt{'l'};
43
44   my $conf = new FS::Conf;
45   my $disable_bill = 0;
46   if ( $conf->exists('disable_cron_billing') ) {
47     warn "disable_cron_billing set, skipping billing\n" if $debug;
48     $disable_bill = 1;
49   }
50
51   #we're at now now (and later).
52   $opt{'time'} = $opt{'d'} ? str2time($opt{'d'}) : $^T;
53   $opt{'time'} += $opt{'y'} * 86400 if $opt{'y'};
54
55   $opt{'invoice_time'} = $opt{'n'} ? $^T : $opt{'time'};
56
57   #hashref here doesn't work with -m
58   #my $not_pkgpart = $opt{g} ? { map { $_=>1 } split(/,\s*/, $opt{g}) }
59   #                          : {};
60
61   ###
62   # get a list of custnums
63   ###
64
65   my $cursor_dbh = dbh->clone;
66
67   my $select = 'SELECT custnum FROM cust_main WHERE '. bill_where( %opt );
68
69   unless ( driver_name =~ /^mysql/ ) {
70     $cursor_dbh->do( "DECLARE cron_bill_cursor CURSOR FOR $select" )
71       or die $cursor_dbh->errstr;
72   }
73
74   while ( 1 ) {
75
76     my $sql = (driver_name =~ /^mysql/)
77       ? $select
78       : 'FETCH 100 FROM cron_bill_cursor';
79
80     my $sth = $cursor_dbh->prepare($sql);
81
82     $sth->execute or die $sth->errstr;
83
84     my @custnums = map { $_->[0] } @{ $sth->fetchall_arrayref };
85
86     last unless scalar(@custnums);
87
88     ###
89     # for each custnum, queue or make one customer object and bill
90     # (one at a time, to reduce memory footprint with large #s of customers)
91     ###
92     
93     foreach my $custnum ( @custnums ) {
94     
95       my %args = (
96           'time'         => $opt{'time'},
97           'invoice_time' => $opt{'invoice_time'},
98           'actual_time'  => $^T, #when freeside-bill was started
99                                  #(not, when using -m, freeside-queued)
100           'check_freq'   => $check_freq,
101           'resetup'      => ( $opt{'s'} ? $opt{'s'} : 0 ),
102           'not_pkgpart'  => $opt{'g'}, #$not_pkgpart,
103           'one_recur'    => $opt{'o'},
104           'no_prepaid'   => 1,
105       );
106
107       if ( $opt{'m'} ) {
108
109         if ( $opt{'r'} ) {
110           warn "DRY RUN: would add custnum $custnum for queued_bill\n";
111         } else {
112
113           my @waiting = qsearch( 'queue', {
114                                    'job'     => 'FS::cust_main::queued_bill',
115                                    'custnum' => $custnum,
116                                    'status'  => 'new',
117                                  }
118                                );
119
120           next if @waiting && ! $opt{'q'};
121
122           #add job to queue that calls bill_and_collect with options
123           my $queue = new FS::queue {
124             'job'      => 'FS::cust_main::queued_bill',
125             'secure'   => 'Y',
126             'priority' => 99, #don't get in the way of provisioning jobs
127           };
128           my $error = $queue->insert( 'custnum'=>$custnum, %args );
129           die $error if $error;
130
131           foreach $waiting_queue (@waiting) {
132             $queue->depend_insert($waiting_queue->jobnum);
133           }
134
135         }
136
137       } else {
138
139         my $cust_main = qsearchs( 'cust_main', { 'custnum' => $custnum } );
140         if ( $disable_bill ) {
141           $cust_main->collect( %args, 'debug' => $debug );
142         } else {
143           my $error = $cust_main->bill_and_collect( %args, 'fatal' => 'return',
144                                                            'debug' => $debug, );
145           if ( $error ) {
146             $log->error($error);
147             warn $error; #die $error;
148           }
149         }
150
151       }
152
153     }
154
155     last if driver_name =~ /^mysql/;
156
157   }
158
159   $cursor_dbh->commit or die $cursor_dbh->errstr;
160
161   $log->info('finish');
162 }
163
164 # freeside-daily %opt:
165 #  -d: Pretend it's 'date'.  Date is in any format Date::Parse is happy with,
166 #      but be careful.
167 #
168 #  -y: In addition to -d, which specifies an absolute date, the -y switch
169 #      specifies an offset, in days.  For example, "-y 15" would increment the
170 #      "pretend date" 15 days from whatever was specified by the -d switch
171 #      (or now, if no -d switch was given).
172 #
173 #  -n: When used with "-d" and/or "-y", specifies that invoices should be dated
174 #      with today's date, regardless of the pretend date used to pre-generate
175 #      the invoices.
176 #
177 #  -a: Only process customers with the specified agentnum
178 #
179 #  -v: enable debugging
180 #
181 #  -l: debugging level
182
183 =item bill_where
184
185 Internal function.  Returns a WHERE clause to select the set of customers who 
186 have actionable packages (no setup date, or bill date in the past, or expire 
187 or adjourn dates in the past) or events (does a complete where_conditions_sql 
188 scan).
189
190 =cut
191
192 sub bill_where {
193   my( %opt ) = @_;
194
195   my $time = $opt{'time'};
196   my $invoice_time = $opt{'invoice_time'};
197
198   my $check_freq = $opt{'check_freq'} || '1d';
199
200   my @search = ();
201
202   push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable?
203
204   push @search, "cust_main.agentnum IN ( ". $opt{'a'}. " ) "
205     if $opt{'a'};
206
207   #it would be useful if i recognized $opt{g} / $not_pkgpart...
208
209   if ( @ARGV ) {
210     push @search, "( ".
211       join(' OR ', map "cust_main.custnum = $_", @ARGV ).
212     " )";
213   }
214
215   ###
216   # generate where_pkg/where_event search clause
217   ###
218
219   my $conf = new FS::Conf;
220   my $billtime = $conf->exists('next-bill-ignore-time') ? day_end($time) : $time;
221
222   # corresponds to perl checks in FS::cust_main::Billing sub bill
223   #  ("bill setup" and "bill recurring fee")
224   # select * from cust_main where
225   my $where_pkg = <<"END";
226     EXISTS(
227       SELECT 1 FROM cust_pkg LEFT JOIN part_pkg USING ( pkgpart )
228         WHERE cust_main.custnum = cust_pkg.custnum
229           AND ( cancel IS NULL OR cancel = 0 )
230           AND (    ( ( cust_pkg.setup IS NULL OR cust_pkg.setup =  0 )
231                      AND ( start_date IS NULL OR start_date = 0
232                            OR ( start_date IS NOT NULL AND start_date <= $billtime )
233                          )
234                    )
235                 OR ( freq != '0' AND ( bill IS NULL OR bill  <= $billtime ) )
236                 OR ( expire  IS NOT NULL AND expire  <= $^T )
237                 OR ( adjourn IS NOT NULL AND adjourn <= $^T )
238                 OR ( resume  IS NOT NULL AND resume  <= $^T )
239               )
240     )
241 END
242
243   #some false laziness w/cust_main::Billing due_cust_event
244   my $where_event = join(' OR ', map {
245     my $eventtable = $_;
246
247     # joins and where clauses to test event conditions
248     my $join  = FS::part_event_condition->join_conditions_sql(  $eventtable,
249                                                                 'time'=>$time );
250     my $where = FS::part_event_condition->where_conditions_sql( $eventtable,
251                                                                 'time'=>$time,
252                                                               );
253     $where = $where ? "AND $where" : '';
254
255     # test to return all applicable part_events (defined on this eventtable,
256     # not disabled, check_freq correct, and all event conditions true)
257     my $are_part_event = 
258       "EXISTS ( SELECT 1 FROM part_event $join
259                   WHERE check_freq = '$check_freq'
260                     AND eventtable = '$eventtable'
261                     AND ( disabled = '' OR disabled IS NULL )
262                     $where
263               )
264       ";
265
266     if ( $eventtable eq 'cust_main' ) { 
267       $are_part_event;
268     } else {
269       my $cust_join = FS::part_event->eventtables_cust_join->{$eventtable}
270                       || '';
271       my $custnum = FS::part_event->eventtables_custnum->{$eventtable};
272       "EXISTS ( SELECT 1 FROM $eventtable $cust_join
273                   WHERE cust_main.custnum = $custnum
274                     AND $are_part_event
275               )
276       ";
277     }
278
279   } FS::part_event->eventtables);
280
281   push @search, "( $where_pkg OR $where_event )";
282
283   warn "searching for customers:\n". join("\n", @search). "\n"
284     if $opt{'v'} || $opt{'l'};
285
286   join(' AND ', @search);
287
288 }
289
290 1;