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