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