1 package FS::part_pkg::prorate_Mixin;
5 use Time::Local qw( timelocal timelocal_nocheck );
6 use Date::Format qw( time2str );
7 use List::Util qw( min );
9 tie our %prorate_round_day_opts, 'Tie::IxHash',
11 1 => 'to the nearest day',
12 2 => 'up to a full day',
13 3 => 'down to a full day',
18 # define all fields that are referenced in this code
20 'add_full_period' => {
21 'name' => 'When prorating first month, also bill for one full '.
25 'prorate_round_day' => {
26 'name' => 'When prorating, round the prorated period',
28 'select_options' => \%prorate_round_day_opts,
30 'prorate_defer_bill' => {
31 'name' => 'When prorating, defer the first bill until the '.
35 'prorate_verbose' => {
36 'name' => 'Show prorate details on the invoice',
40 'fieldorder' => [ qw(prorate_defer_bill prorate_round_day
41 add_full_period prorate_verbose) ],
45 @{ $info{'fieldorder'} }
50 FS::part_pkg::prorate_Mixin - Mixin class for part_pkg:: classes that
51 need to prorate partial months
55 package FS::part_pkg::...;
56 use base qw( FS::part_pkg::prorate_Mixin );
60 if( conditions that trigger prorate ) {
61 # sets $$sdate and $param->{'months'}, returns the prorated charge
62 $charges = $self->calc_prorate($cust_pkg, $sdate, $param, $cutoff_day);
69 =item calc_prorate CUST_PKG SDATE DETAILS PARAM CUTOFF_DAY
71 Takes all the arguments of calc_recur. Calculates a prorated charge from
72 the $sdate to the cutoff day for this package definition, and sets the $sdate
73 and $param->{months} accordingly. base_recur() will be called to determine
74 the base price per billing cycle.
77 - add_full_period: Bill for the time up to the prorate day plus one full
78 billing period after that.
79 - prorate_round_day: Round the current time to the nearest full day,
80 instead of using the exact time.
81 - prorate_defer_bill: Don't bill the prorate interval until the prorate
83 - prorate_verbose: Generate details to explain the prorate calculations.
88 my ($self, $cust_pkg, $sdate, $details, $param, @cutoff_days) = @_;
89 die "no cutoff_day" unless @cutoff_days;
90 die "can't prorate non-monthly package\n" if $self->freq =~ /\D/;
92 my $money_char = FS::Conf->new->config('money_char') || '$';
94 my $charge = $self->base_recur($cust_pkg, $sdate) || 0;
96 my $add_period = $self->option('add_full_period',1);
100 # if this is the first bill but the bill date has been set
101 # (by prorate_defer_bill), calculate from the setup date,
102 # append the setup fee to @$details, and make sure to bill for
103 # a full period after the bill date.
104 if ( $self->option('prorate_defer_bill',1)
105 && ! $cust_pkg->getfield('last_bill')
109 #warn "[calc_prorate] #".$cust_pkg->pkgnum.": running deferred setup\n";
110 $param->{'setup_fee'} = $self->calc_setup($cust_pkg, $$sdate, $details);
111 $mnow = $cust_pkg->setup;
115 # if the customer already has a billing day-of-month established,
116 # and it's a valid cutoff day, try to respect it
118 if ( my $next_bill = $cust_pkg->cust_main->next_bill_date ) {
119 $next_bill_day = (localtime($next_bill))[3];
120 if ( grep {$_ == $next_bill_day} @cutoff_days ) {
121 # by removing all other cutoff days from the list
122 @cutoff_days = ($next_bill_day);
127 ($mnow, $mend, $mstart) = $self->_endpoints($mnow, @cutoff_days);
129 # next bill date will be figured as $$sdate + one period
132 my $permonth = $charge / $self->freq;
133 my $months = ( ( $self->freq - 1 ) + ($mend-$mnow) / ($mend-$mstart) );
134 # after this, $self->freq - 1 < $months <= $self->freq
136 # add a full period if currently billing for a partial period
137 # or periods up to freq_override if billing for an override interval
138 if ( ($param->{'freq_override'} || 0) > 1 ) {
139 $months += $param->{'freq_override'} - 1;
140 # freq_override - 1 correct here?
141 # (probably only if freq == 1, yes?)
142 } elsif ( $add_period && $months < $self->freq ) {
144 # 'add_period' is a misnomer.
145 # we add enough to make the total at least a full period
147 $$sdate = $self->add_freq($mstart, 1);
148 # now $self->freq <= $months <= $self->freq + 1
149 # (note that this only happens if $months < $self->freq to begin with)
153 if ( $self->option('prorate_verbose',1) and $months > 0 ) {
154 if ( $months < $self->freq ) {
155 # we are billing a fractional period only
156 # # (though maybe not a fractional month)
157 my $period_end = $self->add_freq($mstart);
159 'Prorated (' . time2str('%b %d', $mnow) .
160 ' - ' . time2str('%b %d', $period_end) . '): ' . $money_char .
161 sprintf('%.2f', $permonth * $months + 0.00000001 );
163 } elsif ( $months > $self->freq ) {
164 # we are billing MORE than a full period
167 'Prorated (' . time2str('%b %d', $mnow) .
168 ' - ' . time2str('%b %d', $mend) . '): ' . $money_char .
169 sprintf('%.2f', $permonth * ($months - $self->freq + 0.0000001)),
171 'First full period: ' . $money_char .
172 sprintf('%.2f', $permonth * $self->freq);
173 } # else $months == $self->freq, and no prorating has happened
176 $param->{'months'} = $months;
177 #so 1.005 rounds to 1.01
178 $charge = sprintf('%.2f', $permonth * $months + 0.00000001 );
180 return sprintf('%.2f', $charge);
183 =item prorate_setup CUST_PKG SDATE
185 Set up the package. This only has an effect if prorate_defer_bill is
186 set, in which case it postpones the next bill to the cutoff day.
192 my ($cust_pkg, $sdate) = @_;
193 my @cutoff_days = $self->cutoff_day($cust_pkg);
194 if ( ! $cust_pkg->bill
195 and $self->option('prorate_defer_bill',1)
198 my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
199 # If today is the cutoff day, set the next bill and setup both to
200 # midnight today, so that the customer will be billed normally for a
201 # month starting today.
202 if ( $mnow - $mstart < 86400 ) {
203 $cust_pkg->setup($mstart);
204 $cust_pkg->bill($mstart);
207 $cust_pkg->bill($mend);
214 =item _endpoints TIME CUTOFF_DAY
216 Given a current time and a day of the month to prorate to, return three
217 times: the start of the prorate interval (usually the current time), the
218 end of the prorate interval (i.e. the cutoff date), and the time one month
219 before the end of the prorate interval.
226 my @cutoff_days = sort {$a <=> $b} @_;
228 # only works for freq >= 1 month; probably can't be fixed
229 my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
230 my $rounding_mode = $self->option('prorate_round_day',1);
231 if ( $rounding_mode == 1 ) {
232 # If the time is 12:00-23:59, move to the next day by adding 18
233 # hours to $mnow. Because of DST this can end up from 05:00 to 18:59
234 # but it's always within the next day.
235 $mnow += 64800 if $hour >= 12;
236 # Get the new day, month, and year.
237 ($mday,$mon,$year) = (localtime($mnow))[3..5];
238 # Then set $mnow to midnight on that day.
239 $mnow = timelocal(0,0,0,$mday,$mon,$year);
240 } elsif ( $rounding_mode == 2 ) {
241 # Move the time back to midnight. This increases the length of the
243 $mnow = timelocal(0,0,0,$mday,$mon,$year);
244 ($mday,$mon,$year) = (localtime($mnow))[3..5];
245 } elsif ( $rounding_mode == 3 ) {
246 # If the time is after midnight, move it forward to the next midnight.
247 # This decreases the length of the prorate interval.
248 if ( $sec > 0 or $min > 0 or $hour > 0 ) {
249 # move to one second before midnight, then tick forward
250 $mnow = timelocal(59,59,23,$mday,$mon,$year) + 1;
251 ($mday,$mon,$year) = (localtime($mnow))[3..5];
256 # select the first cutoff day that's on or after the current day
257 my $cutoff_day = min( grep { $_ >= $mday } @cutoff_days );
258 # if today is after the last cutoff, choose the first one
259 $cutoff_day ||= $cutoff_days[0];
261 # then, if today is on or after the selected day, set period to
262 # (cutoff day this month) - (cutoff day next month)
263 if ( $mday >= $cutoff_day ) {
265 timelocal_nocheck(0,0,0,$cutoff_day,$mon == 11 ? 0 : $mon + 1,$year+($mon==11));
267 timelocal_nocheck(0,0,0,$cutoff_day,$mon,$year);
269 # otherwise, set period to (cutoff day last month) - (cutoff day this month)
272 timelocal_nocheck(0,0,0,$cutoff_day,$mon,$year);
274 timelocal_nocheck(0,0,0,$cutoff_day,$mon == 0 ? 11 : $mon - 1,$year-($mon==0));
276 return ($mnow, $mend, $mstart);