1 package FS::part_pkg::prorate_calendar;
4 use vars qw(@ISA %info);
7 use base 'FS::part_pkg::flat';
12 'name' => 'Prorate to specific calendar day(s), then flat-rate',
13 'shortname' => 'Prorate (calendar cycle)',
14 'inherit_fields' => [ 'flat', 'usage_Mixin', 'global_Mixin' ],
16 'recur_temporality' => {'disabled' => 1},
17 'sync_bill_date' => {'disabled' => 1},# god help us all
19 'cutoff_day' => { 'name' => 'Billing day (1 - end of cycle)',
23 # add_full_period is not allowed
25 # prorate_round_day is always on
26 'prorate_round_day' => { 'disabled' => 1 },
28 'prorate_defer_bill'=> {
29 'name' => 'Defer the first bill until the billing day',
32 'prorate_verbose' => {
33 'name' => 'Show prorate details on the invoice',
37 'fieldorder' => [ 'cutoff_day', 'prorate_defer_bill', 'prorate_round_day', 'prorate_verbose' ],
42 my %freq_max_days = ( # the length of the shortest period of each cycle type
44 '2' => 59, # Jan - Feb
45 '3' => 90, # Jan - Mar
46 '4' => 120, # Jan - Apr
47 '6' => 181, # Jan - Jun
51 my %freq_cutoff_days = (
52 '1' => [ 31, 28, 31, 30, 31, 30,
53 31, 31, 30, 31, 30, 31 ],
54 '2' => [ 59, 61, 61, 62, 61, 61 ],
55 '3' => [ 90, 91, 92, 92 ],
56 '4' => [ 120, 123, 122 ],
62 # yes, this package plan is such a special snowflake it needs its own
66 if ( !exists($freq_max_days{$self->freq}) ) {
67 return 'Prorate (calendar cycle) billing interval must be an integer factor of one year';
73 my( $self, $cust_pkg ) = @_;
74 my @periods = @{ $freq_cutoff_days{$self->freq} };
75 my @cutoffs = ($self->option('cutoff_day') || 1); # Jan 1 = 1
76 pop @periods; # we don't care about the last one
78 push @cutoffs, $cutoffs[-1] + $_;
84 # it's not the same algorithm
85 my ($self, $cust_pkg, $sdate, $details, $param, @cutoff_days) = @_;
86 die "no cutoff_day" unless @cutoff_days;
87 die "prepaid terms not supported with calendar prorate packages"
88 if $param->{freq_override}; # XXX if we ever use this again
90 #XXX should we still be doing this with multi-currency support?
91 my $money_char = FS::Conf->new->config('money_char') || '$';
93 my $charge = $self->base_recur($cust_pkg, $sdate) || 0;
94 my $now = DateTime->from_epoch(epoch => $$sdate, time_zone => 'local');
97 # if this is the first bill but the bill date has been set
98 # (by prorate_defer_bill), calculate from the setup date,
99 # append the setup fee to @$details, and make sure to bill for
100 # a full period after the bill date.
102 if ( $self->option('prorate_defer_bill', 1)
103 and !$cust_pkg->getfield('last_bill')
104 and $cust_pkg->setup )
106 $param->{'setup_fee'} = $self->calc_setup($cust_pkg, $$sdate, $details);
107 $now = DateTime->from_epoch(epoch => $cust_pkg->setup, time_zone => 'local');
111 # DON'T sync to the existing billing day; cutoff days work differently here.
113 $now->truncate(to => 'day');
114 my ($end, $start) = $self->calendar_endpoints($now, @cutoff_days);
116 #warn "[prorate_calendar] now = ".$now->ymd.", start = ".$start->ymd.", end = ".$end->ymd."\n";
118 my $periods = $end->delta_days($now)->delta_days /
119 $end->delta_days($start)->delta_days;
120 if ( $periods < 1 and $add_period ) {
121 $periods++; # charge for the extra time
122 $start->add(months => $self->freq); # and push the next bill date forward
124 if ( $self->option('prorate_verbose',1) and $periods > 0 ) {
125 if ( $periods < 1 ) {
127 'Prorated (' . $now->strftime('%b %d') .
128 ' - ' . $end->strftime('%b %d') . '): ' . $money_char .
129 sprintf('%.2f', $charge * $periods + 0.00000001);
130 } elsif ( $periods > 1 ) {
132 'Prorated (' . $now->strftime('%b %d') .
133 ' - ' . $end->strftime('%b %d') . '): ' . $money_char .
134 sprintf('%.2f', $charge * ($periods - 1) + 0.00000001),
136 'First full period: ' . $money_char . sprintf('%.2f', $charge);
137 } # else exactly one period
140 $$sdate = $start->epoch;
141 return sprintf('%.2f', $charge * $periods + 0.00000001);
146 my ($cust_pkg, $sdate) = @_;
147 my @cutoff_days = $self->cutoff_day;
148 if ( ! $cust_pkg->bill
149 and $self->option('prorate_defer_bill')
152 my $now = DateTime->from_epoch(epoch => $sdate, time_zone => 'local');
153 $now->truncate(to => 'day');
154 my ($end, $start) = $self->calendar_endpoints($now, @cutoff_days);
155 if ( $now->compare($start) == 0 ) {
156 $cust_pkg->setup($start->epoch);
157 $cust_pkg->bill($start->epoch);
159 $cust_pkg->bill($end->epoch);
167 =item calendar_endpoints NOW CUTOFF_DAYS
169 Given a current date (DateTime object) and a list of cutoff day-of-year
170 numbers, finds the next upcoming cutoff day (in either the current or the
171 upcoming year) and the cutoff day before that, and returns them both.
175 sub calendar_endpoints {
178 my @cutoff_day = sort {$a <=> $b} @_;
180 my $year = $now->year;
181 my $day = $now->day_of_year;
183 # For cutoff day purposes, it's the same day as Feb 28
184 $day-- if $now->is_leap_year and $day >= 60;
186 # select the first cutoff day that's after the current day
188 while ( $cutoff_day[$i] and $cutoff_day[$i] <= $day ) {
191 # $cutoff_day[$i] is now later in the calendar than today
192 # or today is between the last cutoff day and the end of the year
196 # then today is on or before the first cutoff day
197 $start = DateTime->from_day_of_year(year => $year - 1,
198 day_of_year => $cutoff_day[-1],
199 time_zone => 'local');
200 $end = DateTime->from_day_of_year(year => $year,
201 day_of_year => $cutoff_day[0],
202 time_zone => 'local');
203 } elsif ( $i > 0 and $i < scalar(@cutoff_day) ) {
204 # today is between two cutoff days
205 $start = DateTime->from_day_of_year(year => $year,
206 day_of_year => $cutoff_day[$i - 1],
207 time_zone => 'local');
208 $end = DateTime->from_day_of_year(year => $year,
209 day_of_year => $cutoff_day[$i],
210 time_zone => 'local');
212 # today is after the last cutoff day
213 $start = DateTime->from_day_of_year(year => $year,
214 day_of_year => $cutoff_day[-1],
215 time_zone => 'local');
216 $end = DateTime->from_day_of_year(year => $year + 1,
217 day_of_year => $cutoff_day[0],
218 time_zone => 'local');
220 return ($end, $start);