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