freeside-daily optimization
[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 @ISA = qw( Exporter );
17 @EXPORT_OK = qw ( bill bill_where );
18
19 #freeside-daily %opt:
20 #  -s: re-charge setup fees
21 #  -v: enable debugging
22 #  -l: debugging level
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
26
27 sub bill {
28   my %opt = @_;
29
30   my $check_freq = $opt{'check_freq'} || '1d';
31
32   my $debug = 0;
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'};
37
38   my $conf = new FS::Conf;
39   if ( $conf->exists('disable_cron_billing') ) {
40     warn "disable_cron_billing set, skipping billing\n" if $debug;
41     return;
42   }
43
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'};
47
48   $opt{'invoice_time'} = $opt{'n'} ? $^T : $opt{'time'};
49
50   #hashref here doesn't work with -m
51   #my $not_pkgpart = $opt{g} ? { map { $_=>1 } split(/,\s*/, $opt{g}) }
52   #                          : {};
53
54   ###
55   # get a list of custnums
56   ###
57
58   my $cursor_dbh = dbh->clone;
59
60   my $select = 'SELECT custnum FROM cust_main WHERE '. bill_where( %opt );
61
62   unless ( driver_name =~ /^mysql/ ) {
63     $cursor_dbh->do( "DECLARE cron_bill_cursor CURSOR FOR $select" )
64       or die $cursor_dbh->errstr;
65   }
66
67   while ( 1 ) {
68
69     my $sql = (driver_name =~ /^mysql/)
70       ? $select
71       : 'FETCH 100 FROM cron_bill_cursor';
72
73     my $sth = $cursor_dbh->prepare($sql);
74
75     $sth->execute or die $sth->errstr;
76
77     my @custnums = map { $_->[0] } @{ $sth->fetchall_arrayref };
78
79     last unless scalar(@custnums);
80
81     ###
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)
84     ###
85     
86     foreach my $custnum ( @custnums ) {
87     
88       my %args = (
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,
96           'one_recur'    => $opt{'o'},
97       );
98
99       if ( $opt{'m'} ) {
100
101         if ( $opt{'r'} ) {
102           warn "DRY RUN: would add custnum $custnum for queued_bill\n";
103         } else {
104
105           #avoid queuing another job if there's one still waiting to run
106           next if qsearch( 'queue', { 'job'     => 'FS::cust_main::queued_bill',
107                                       'custnum' => $custnum,
108                                       'status'  => 'new',
109                                     }
110                          );
111
112           #add job to queue that calls bill_and_collect with options
113           my $queue = new FS::queue {
114             'job'      => 'FS::cust_main::queued_bill',
115             'secure'   => 'Y',
116             'priority' => 99, #don't get in the way of provisioning jobs
117           };
118           my $error = $queue->insert( 'custnum'=>$custnum, %args );
119
120         }
121
122       } else {
123
124         my $cust_main = qsearchs( 'cust_main', { 'custnum' => $custnum } );
125         $cust_main->bill_and_collect( %args, 'debug' => $debug );
126
127       }
128
129     }
130
131     last if driver_name =~ /^mysql/;
132
133   }
134
135   $cursor_dbh->commit or die $cursor_dbh->errstr;
136
137 }
138
139 # freeside-daily %opt:
140 #  -d: Pretend it's 'date'.  Date is in any format Date::Parse is happy with,
141 #      but be careful.
142 #
143 #  -y: In addition to -d, which specifies an absolute date, the -y switch
144 #      specifies an offset, in days.  For example, "-y 15" would increment the
145 #      "pretend date" 15 days from whatever was specified by the -d switch
146 #      (or now, if no -d switch was given).
147 #
148 #  -n: When used with "-d" and/or "-y", specifies that invoices should be dated
149 #      with today's date, regardless of the pretend date used to pre-generate
150 #      the invoices.
151 #
152 #  -p: Only process customers with the specified payby (I<CARD>, I<DCRD>, I<CHEK>, I<DCHK>, I<BILL>, I<COMP>, I<LECB>)
153 #
154 #  -a: Only process customers with the specified agentnum
155 #
156 #  -v: enable debugging
157 #
158 #  -l: debugging level
159
160 =item bill_where
161
162 Internal function.  Returns a WHERE clause to select the set of customers who 
163 have actionable packages (no setup date, or bill date in the past, or expire 
164 or adjourn dates in the past) or events (does a complete where_conditions_sql 
165 scan).
166
167 =cut
168
169 sub bill_where {
170   my( %opt ) = @_;
171
172   my $time = $opt{'time'};
173   my $invoice_time = $opt{'invoice_time'};
174
175   my $check_freq = $opt{'check_freq'} || '1d';
176
177   my @search = ();
178
179   push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable?
180
181   push @search, "cust_main.payby    = '". $opt{'p'}. "'"
182     if $opt{'p'};
183   push @search, "cust_main.agentnum IN ( ". $opt{'a'}. " ) "
184     if $opt{'a'};
185
186   #it would be useful if i recognized $opt{g} / $not_pkgpart...
187
188   if ( @ARGV ) {
189     push @search, "( ".
190       join(' OR ', map "cust_main.custnum = $_", @ARGV ).
191     " )";
192   }
193
194   ###
195   # generate where_pkg/where_event search clause
196   ###
197
198   my $billtime = day_end($time);
199
200   # select * from cust_main where
201   my $where_pkg = <<"END";
202     EXISTS(
203       SELECT 1 FROM cust_pkg LEFT JOIN part_pkg USING ( pkgpart )
204         WHERE cust_main.custnum = cust_pkg.custnum
205           AND ( cancel IS NULL OR cancel = 0 )
206           AND (    ( ( cust_pkg.setup IS NULL OR cust_pkg.setup =  0 )
207                      AND ( start_date IS NULL OR start_date = 0
208                            OR ( start_date IS NOT NULL AND start_date <= $^T )
209                          )
210                    )
211                 OR ( freq != '0' AND ( bill IS NULL OR bill  <= $billtime ) )
212                 OR ( expire  IS NOT NULL AND expire  <= $^T )
213                 OR ( adjourn IS NOT NULL AND adjourn <= $^T )
214                 OR ( resume  IS NOT NULL AND resume  <= $^T )
215               )
216     )
217 END
218
219   #some false laziness w/cust_main::Billing due_cust_event
220   my $where_event = join(' OR ', map {
221     my $eventtable = $_;
222
223     # joins and where clauses to test event conditions
224     my $join  = FS::part_event_condition->join_conditions_sql(  $eventtable );
225     my $where = FS::part_event_condition->where_conditions_sql( $eventtable,
226                                                                 'time'=>$time,
227                                                               );
228     $where = $where ? "AND $where" : '';
229
230     # test to return all applicable part_events (defined on this eventtable,
231     # not disabled, check_freq correct, and all event conditions true)
232     my $are_part_event = 
233       "EXISTS ( SELECT 1 FROM part_event $join
234                   WHERE check_freq = '$check_freq'
235                     AND eventtable = '$eventtable'
236                     AND ( disabled = '' OR disabled IS NULL )
237                     $where
238               )
239       ";
240
241     if ( $eventtable eq 'cust_main' ) { 
242       $are_part_event;
243     } else {
244       my $cust_join = FS::part_event->eventtables_cust_join->{$eventtable}
245                       || '';
246       my $custnum = FS::part_event->eventtables_custnum->{$eventtable};
247       "EXISTS ( SELECT 1 FROM $eventtable $cust_join
248                   WHERE cust_main.custnum = $custnum
249                     AND $are_part_event
250               )
251       ";
252     }
253
254   } FS::part_event->eventtables);
255
256   push @search, "( $where_pkg OR $where_event )";
257
258   warn "searching for customers:\n". join("\n", @search). "\n"
259     if $opt{'v'} || $opt{'l'};
260
261   join(' AND ', @search);
262
263 }
264
265 1;