add prorate options to recur_Common packages, #10630
[freeside.git] / FS / FS / part_pkg / prorate_Mixin.pm
1 package FS::part_pkg::prorate_Mixin;
2
3 use strict;
4 use vars qw(@ISA %info);
5 use Time::Local qw(timelocal);
6
7 @ISA = qw(FS::part_pkg);
8 %info = ( 
9   'disabled'  => 1,
10   # define all fields that are referenced in this code
11   'fields' => {
12     'add_full_period' => { 
13                 'name' => 'When prorating first month, also bill for one full '.
14                           'period after that',
15                 'type' => 'checkbox',
16     },
17     'prorate_round_day' => { 
18                 'name' => 'When prorating, round to the nearest full day',
19                 'type' => 'checkbox',
20     },
21     'prorate_defer_bill' => {
22                 'name' => 'When prorating, defer the first bill until the '.
23                           'billing day',
24                 'type' => 'checkbox',
25     },
26   },
27   'fieldorder' => [ qw(prorate_defer_bill prorate_round_day add_full_period) ],
28 );
29
30 sub fieldorder {
31   @{ $info{'fieldorder'} }
32 }
33
34 =head1 NAME
35
36 FS::part_pkg::prorate_Mixin - Mixin class for part_pkg:: classes that 
37 need to prorate partial months
38
39 =head1 SYNOPSIS
40
41 package FS::part_pkg::...;
42 use base qw( FS::part_pkg::prorate_Mixin );
43
44 sub calc_recur {
45   ...
46   if( conditions that trigger prorate ) {
47     # sets $$sdate and $param->{'months'}, returns the prorated charge
48     $charges = $self->calc_prorate($cust_pkg, $sdate, $param, $cutoff_day);
49   } 
50   ...
51 }
52
53 =head METHODS
54
55 =item calc_prorate CUST_PKG SDATE DETAILS PARAM CUTOFF_DAY
56
57 Takes all the arguments of calc_recur.  Calculates a prorated charge from 
58 the $sdate to the cutoff day for this package definition, and sets the $sdate 
59 and $param->{months} accordingly.  base_recur() will be called to determine 
60 the base price per billing cycle.
61
62 Options:
63 - add_full_period: Bill for the time up to the prorate day plus one full
64 billing period after that.
65 - prorate_round_day: Round the current time to the nearest full day, 
66 instead of using the exact time.
67 - prorate_defer_bill: Don't bill the prorate interval until the prorate 
68 day arrives.
69
70 =cut
71
72 sub calc_prorate {
73   my ($self, $cust_pkg, $sdate, $details, $param, $cutoff_day) = @_;
74   die "no cutoff_day" unless $cutoff_day;
75   die "can't prorate non-monthly package\n" if $self->freq =~ /\D/;
76
77   my $charge = $self->base_recur($cust_pkg, $sdate) || 0;
78
79     my $mnow = $$sdate;
80
81     # if this is the first bill but the bill date has been set
82     # (by prorate_defer_bill), calculate from the setup date,
83     # and append the setup fee to @$details.
84     if ( $self->option('prorate_defer_bill',1)
85         and ! $cust_pkg->getfield('last_bill') 
86         and $cust_pkg->setup ) {
87       #warn "[calc_prorate] #".$cust_pkg->pkgnum.": running deferred setup\n";
88       $param->{'setup_fee'} = $self->calc_setup($cust_pkg, $$sdate, $details);
89       $mnow = $cust_pkg->setup;
90     }
91
92     my ($mend, $mstart);
93     ($mnow, $mend, $mstart) = $self->_endpoints($mnow, $cutoff_day);
94
95     # next bill date will be figured as $$sdate + one period
96     $$sdate = $mstart;
97
98     my $permonth = $charge / $self->freq;
99     my $months = ( ( $self->freq - 1 ) + ($mend-$mnow) / ($mend-$mstart) );
100
101     # add a full period if currently billing for a partial period
102     if ( ( $self->option('add_full_period',1) 
103         or $self->option('prorate_defer_bill',1) ) # necessary
104         and $months < $self->freq ) {
105       $months += $self->freq;
106       $$sdate = $self->add_freq($mstart);
107     }
108
109     $param->{'months'} = $months;
110     $charge = sprintf('%.2f', $permonth * $months);
111
112   return $charge;
113 }
114
115 =item prorate_setup CUST_PKG SDATE
116
117 Set up the package.  This only has an effect if prorate_defer_bill is 
118 set, in which case it postpones the next bill to the cutoff day.
119
120 =cut
121
122 sub prorate_setup {
123   my $self = shift;
124   my ($cust_pkg, $sdate) = @_;
125   my $cutoff_day = $self->cutoff_day($cust_pkg);
126   if ( ! $cust_pkg->bill
127       and $self->option('prorate_defer_bill',1)
128       and $cutoff_day
129   ) {
130     my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, $cutoff_day);
131     # if today is the cutoff day, set the next bill to right now instead 
132     # of waiting a month.
133     if ( $mnow - $mstart < 86400 ) {
134       $cust_pkg->bill($mnow);
135     }
136     else {
137       $cust_pkg->bill($mend);
138     }
139     return 1;
140   }
141   return 0;
142 }
143
144 =item _endpoints TIME CUTOFF_DAY
145
146 Given a current time and a day of the month to prorate to, return three 
147 times: the start of the prorate interval (usually the current time), the
148 end of the prorate interval (i.e. the cutoff date), and the time one month 
149 before the end of the prorate interval.
150
151 =cut
152
153 sub _endpoints {
154   my ($self, $mnow, $cutoff_day) = @_;
155
156   # only works for freq >= 1 month; probably can't be fixed
157   my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
158   if( $self->option('prorate_round_day',1) ) {
159     # If the time is 12:00-23:59, move to the next day by adding 18 
160     # hours to $mnow.  Because of DST this can end up from 05:00 to 18:59
161     # but it's always within the next day.
162     $mnow += 64800 if $hour >= 12;
163     # Get the new day, month, and year.
164     ($mday,$mon,$year) = (localtime($mnow))[3..5];
165     # Then set $mnow to midnight on that day.
166     $mnow = timelocal(0,0,0,$mday,$mon,$year);
167   }
168   my $mend;
169   my $mstart;
170   # if cutoff day > 28, force it to the 1st of next month
171   if ( $cutoff_day > 28 ) {
172     $cutoff_day = 1;
173     # and if we are currently after the 28th, roll the current day 
174     # forward to that day
175     if ( $mday > 28 ) {
176       $mday = 1;
177       #set $mnow = $mend so the amount billed will be zero
178       $mnow = timelocal(0,0,0,1,$mon == 11 ? 0 : $mon + 1,$year+($mon==11));
179     }
180   }
181   if ( $mday >= $cutoff_day ) {
182     $mend = 
183       timelocal(0,0,0,$cutoff_day,$mon == 11 ? 0 : $mon + 1,$year+($mon==11));
184     $mstart =
185       timelocal(0,0,0,$cutoff_day,$mon,$year);
186   }
187   else {
188     $mend = 
189       timelocal(0,0,0,$cutoff_day,$mon,$year);
190     $mstart = 
191       timelocal(0,0,0,$cutoff_day,$mon == 0 ? 11 : $mon - 1,$year-($mon==0));
192   }
193   return ($mnow, $mend, $mstart);
194 }
195
196 1;