fix contract_end bug, RT#10319
[freeside.git] / FS / FS / part_pkg / flat.pm
1 package FS::part_pkg::flat;
2
3 use strict;
4 use vars qw( @ISA %info
5              %usage_fields %usage_recharge_fields
6              @usage_fieldorder @usage_recharge_fieldorder
7            );
8 use Tie::IxHash;
9 use List::Util qw(min); # max);
10 #use FS::Record qw(qsearch);
11 use FS::UI::bytecount;
12 use FS::Conf;
13 use FS::part_pkg;
14 use FS::cust_bill_pkg_discount;
15
16 @ISA = qw(FS::part_pkg FS::part_pkg::prorate_Mixin);
17
18 tie my %temporalities, 'Tie::IxHash',
19   'upcoming'  => "Upcoming (future)",
20   'preceding' => "Preceding (past)",
21 ;
22
23 tie my %contract_years, 'Tie::IxHash', (
24   '' => '(none)',
25   map { $_*12 => $_ } (1..5),
26 );
27
28 %usage_fields = (
29
30     'seconds'       => { 'name' => 'Time limit for this package',
31                          'default' => '',
32                          'check' => sub { shift =~ /^\d*$/ },
33                        },
34     'upbytes'       => { 'name' => 'Upload limit for this package',
35                          'default' => '',
36                          'check' => sub { shift =~ /^\d*$/ },
37                          'format' => \&FS::UI::bytecount::display_bytecount,
38                          'parse' => \&FS::UI::bytecount::parse_bytecount,
39                        },
40     'downbytes'     => { 'name' => 'Download limit for this package',
41                          'default' => '',
42                          'check' => sub { shift =~ /^\d*$/ },
43                          'format' => \&FS::UI::bytecount::display_bytecount,
44                          'parse' => \&FS::UI::bytecount::parse_bytecount,
45                        },
46     'totalbytes'    => { 'name' => 'Transfer limit for this package',
47                          'default' => '',
48                          'check' => sub { shift =~ /^\d*$/ },
49                          'format' => \&FS::UI::bytecount::display_bytecount,
50                          'parse' => \&FS::UI::bytecount::parse_bytecount,
51                        },
52 );
53
54 %usage_recharge_fields = (
55
56     'recharge_amount'       => { 'name' => 'Cost of recharge for this package',
57                          'default' => '',
58                          'check' => sub { shift =~ /^\d*(\.\d{2})?$/ },
59                        },
60     'recharge_seconds'      => { 'name' => 'Recharge time for this package',
61                          'default' => '',
62                          'check' => sub { shift =~ /^\d*$/ },
63                        },
64     'recharge_upbytes'      => { 'name' => 'Recharge upload for this package',
65                          'default' => '',
66                          'check' => sub { shift =~ /^\d*$/ },
67                          'format' => \&FS::UI::bytecount::display_bytecount,
68                          'parse' => \&FS::UI::bytecount::parse_bytecount,
69                        },
70     'recharge_downbytes'    => { 'name' => 'Recharge download for this package',
71                          'default' => '',
72                          'check' => sub { shift =~ /^\d*$/ },
73                          'format' => \&FS::UI::bytecount::display_bytecount,
74                          'parse' => \&FS::UI::bytecount::parse_bytecount,
75                        },
76     'recharge_totalbytes'   => { 'name' => 'Recharge transfer for this package',
77                          'default' => '',
78                          'check' => sub { shift =~ /^\d*$/ },
79                          'format' => \&FS::UI::bytecount::display_bytecount,
80                          'parse' => \&FS::UI::bytecount::parse_bytecount,
81                        },
82     'usage_rollover' => { 'name' => 'Allow usage from previous period to roll '.
83                                     ' over into current period',
84                           'type' => 'checkbox',
85                         },
86     'recharge_reset' => { 'name' => 'Reset usage to these values on manual '.
87                                     'package recharge',
88                           'type' => 'checkbox',
89                         },
90 );
91
92 @usage_fieldorder = qw( seconds upbytes downbytes totalbytes );
93 @usage_recharge_fieldorder = qw(
94   recharge_amount recharge_seconds recharge_upbytes
95   recharge_downbytes recharge_totalbytes
96   usage_rollover recharge_reset
97 );
98
99 %info = (
100   'name' => 'Flat rate (anniversary billing)',
101   'shortname' => 'Anniversary',
102   'fields' => {
103     'setup_fee'     => { 'name' => 'Setup fee for this package',
104                          'default' => 0,
105                        },
106     'recur_fee'     => { 'name' => 'Recurring fee for this package',
107                          'default' => 0,
108                        },
109
110     #false laziness w/voip_cdr.pm
111     'recur_temporality' => { 'name' => 'Charge recurring fee for period',
112                              'type' => 'select',
113                              'select_options' => \%temporalities,
114                            },
115     'unused_credit' => { 'name' => 'Credit the customer for the unused portion'.
116                                    ' of service at cancellation',
117                          'type' => 'checkbox',
118                        },
119
120     #used in cust_pkg.pm so could add to any price plan
121     'expire_months' => { 'name' => 'Auto-add an expiration date this number of months out',
122                        },
123     'adjourn_months'=> { 'name' => 'Auto-add a suspension date this number of months out',
124                        },
125     'contract_end_months'=> { 
126                         'name' => 'Auto-add a contract end date this number of years out',
127                         'type' => 'select',
128                         'select_options' => \%contract_years,
129                       },
130     #used in cust_pkg.pm so could add to any price plan where it made sense
131     'start_1st'     => { 'name' => 'Auto-add a start date to the 1st, ignoring the current month.',
132                          'type' => 'checkbox',
133                        },
134     'sync_bill_date' => { 'name' => 'Prorate first month to synchronize '.
135                                     'with the customer\'s other packages',
136                           'type' => 'checkbox',
137                         },
138     'suspend_bill' => { 'name' => 'Continue recurring billing while suspended',
139                         'type' => 'checkbox',
140                       },
141     'unsuspend_adjust_bill' => 
142                         { 'name' => 'Adjust next bill date forward when '.
143                                     'unsuspending',
144                           'type' => 'checkbox',
145                         },
146
147     %usage_fields,
148     %usage_recharge_fields,
149
150     'externalid' => { 'name'   => 'Optional External ID',
151                       'default' => '',
152                     },
153   },
154   'fieldorder' => [ qw( setup_fee recur_fee
155                         recur_temporality unused_credit
156                         expire_months adjourn_months
157                         contract_end_months
158                         start_1st sync_bill_date
159                         suspend_bill unsuspend_adjust_bill
160                       ),
161                     @usage_fieldorder, @usage_recharge_fieldorder,
162                     qw( externalid ),
163                   ],
164   'weight' => 10,
165 );
166
167 sub calc_setup {
168   my($self, $cust_pkg, $sdate, $details ) = @_;
169
170   my $i = 0;
171   my $count = $self->option( 'additional_count', 'quiet' ) || 0;
172   while ($i < $count) {
173     push @$details, $self->option( 'additional_info' . $i++ );
174   }
175
176   my $quantity = $cust_pkg->quantity || 1;
177
178   sprintf("%.2f", $quantity * $self->unit_setup($cust_pkg, $sdate, $details) );
179 }
180
181 sub unit_setup {
182   my($self, $cust_pkg, $sdate, $details ) = @_;
183
184   $self->option('setup_fee') || 0;
185 }
186
187 sub calc_recur {
188   my $self = shift;
189   my($cust_pkg, $sdate, $details, $param ) = @_;
190
191   #my $last_bill = $cust_pkg->last_bill;
192   my $last_bill = $cust_pkg->get('last_bill'); #->last_bill falls back to setup
193
194   return 0
195     if $self->option('recur_temporality', 1) eq 'preceding' && $last_bill == 0;
196
197   if( $self->option('sync_bill_date',1) ) {
198     return $self->calc_prorate(@_);
199   }
200   else {
201     my $charge = $self->base_recur($cust_pkg);
202     $charge *= $param->{freq_override} if $param->{freq_override};
203     my $discount = $self->calc_discount($cust_pkg, $sdate, $details, $param);
204
205     return sprintf('%.2f', $charge - $discount);
206   }
207 }
208
209 sub calc_discount {
210   my($self, $cust_pkg, $sdate, $details, $param ) = @_;
211
212   my $br = $self->base_recur($cust_pkg);
213
214   my $tot_discount = 0;
215   #UI enforces just 1 for now, will need ordering when they can be stacked
216
217   if ( $param->{freq_override} ) {
218     my $real_part_pkg = new FS::part_pkg { $self->hash };
219     $real_part_pkg->pkgpart($param->{real_pkgpart} || $self->pkgpart);
220     my @discount = grep { $_->months == $param->{freq_override} }
221                    map { $_->discount }
222                    $real_part_pkg->part_pkg_discount;
223     my $discount = shift @discount;
224     $param->{months} = $param->{freq_override} unless $param->{months};
225     my $error;
226     if ($discount) {
227       if ($discount->months == $param->{months}) {
228         $cust_pkg->discountnum($discount->discountnum);
229         $error = $cust_pkg->insert_discount;
230       } else {
231         $cust_pkg->discountnum(-1);
232         foreach ( qw( amount percent months ) ) {
233           my $method = "discountnum_$_";
234           $cust_pkg->$method($discount->$_);
235         }
236         $error = $cust_pkg->insert_discount;
237       }
238       die "error discounting using part_pkg_discount: $error" if $error;
239     }
240   }
241
242   my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active;
243   foreach my $cust_pkg_discount ( @cust_pkg_discount ) {
244      my $discount = $cust_pkg_discount->discount;
245      #UI enforces one or the other (for now?  probably for good)
246      my $amount = 0;
247      $amount += $discount->amount
248        if $cust_pkg->pkgpart == $param->{real_pkgpart};
249      $amount += sprintf('%.2f', $discount->percent * $br / 100 );
250
251      my $chg_months = $param->{'months'} || $cust_pkg->part_pkg->freq;
252      
253      my $months = $discount->months
254                     ? min( $chg_months,
255                            $discount->months - $cust_pkg_discount->months_used )
256                     : $chg_months;
257
258      my $error = $cust_pkg_discount->increment_months_used($months)
259        if $cust_pkg->pkgpart == $param->{real_pkgpart};
260      die "error discounting: $error" if $error;
261
262      $amount *= $months;
263      $amount = sprintf('%.2f', $amount);
264
265      next unless $amount > 0;
266
267      #record details in cust_bill_pkg_discount
268      my $cust_bill_pkg_discount = new FS::cust_bill_pkg_discount {
269        'pkgdiscountnum' => $cust_pkg_discount->pkgdiscountnum,
270        'amount'         => $amount,
271        'months'         => $months,
272      };
273      push @{ $param->{'discounts'} }, $cust_bill_pkg_discount;
274
275      #add details on discount to invoice
276      my $conf = new FS::Conf;
277      my $money_char = $conf->config('money_char') || '$';  
278      $months = sprintf('%.2f', $months) if $months =~ /\./;
279
280      my $d = 'Includes ';
281      $d .= $discount->name. ' ' if $discount->name;
282      $d .= 'discount of '. $discount->description_short;
283      $d .= " for $months month". ( $months!=1 ? 's' : '' );
284      $d .= ": $money_char$amount" if $months != 1 || $discount->percent;
285      push @$details, $d;
286
287      $tot_discount += $amount;
288   }
289
290   sprintf('%.2f', $tot_discount);
291 }
292
293 sub base_recur {
294   my($self, $cust_pkg) = @_;
295   $self->option('recur_fee', 1) || 0;
296 }
297
298 sub base_recur_permonth {
299   my($self, $cust_pkg) = @_;
300
301   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
302
303   sprintf('%.2f', $self->base_recur($cust_pkg) / $self->freq );
304 }
305
306 sub calc_remain {
307   my ($self, $cust_pkg, %options) = @_;
308
309   my $time;
310   if ($options{'time'}) {
311     $time = $options{'time'};
312   } else {
313     $time = time;
314   }
315
316   my $next_bill = $cust_pkg->getfield('bill') || 0;
317
318   #my $last_bill = $cust_pkg->last_bill || 0;
319   my $last_bill = $cust_pkg->get('last_bill') || 0; #->last_bill falls back to setup
320
321   return 0 if    ! $self->base_recur($cust_pkg)
322               || ! $self->option('unused_credit', 1)
323               || ! $last_bill
324               || ! $next_bill
325               || $next_bill < $time;
326
327   my %sec = (
328     'h' =>    3600, # 60 * 60
329     'd' =>   86400, # 60 * 60 * 24
330     'w' =>  604800, # 60 * 60 * 24 * 7
331     'm' => 2629744, # 60 * 60 * 24 * 365.2422 / 12 
332   );
333
334   $self->freq =~ /^(\d+)([hdwm]?)$/
335     or die 'unparsable frequency: '. $self->freq;
336   my $freq_sec = $1 * $sec{$2||'m'};
337   return 0 unless $freq_sec;
338
339   sprintf("%.2f", $self->base_recur($cust_pkg) * ( $next_bill - $time ) / $freq_sec );
340
341 }
342
343 sub is_free_options {
344   qw( setup_fee recur_fee );
345 }
346
347 sub is_prepaid { 0; } #no, we're postpaid
348
349 #XXX discounts only on recurring fees for now (no setup/one-time or usage)
350 sub can_discount {
351   my $self = shift;
352   $self->freq =~ /^\d+$/ && $self->freq > 0;
353 }
354
355 sub usage_valuehash {
356   my $self = shift;
357   map { $_, $self->option($_) }
358     grep { $self->option($_, 'hush') } 
359     qw(seconds upbytes downbytes totalbytes);
360 }
361
362 sub reset_usage {
363   my($self, $cust_pkg, %opt) = @_;
364   warn "   resetting usage counters" if defined($opt{debug}) && $opt{debug} > 1;
365   my %values = $self->usage_valuehash;
366   if ($self->option('usage_rollover', 1)) {
367     $cust_pkg->recharge(\%values);
368   }else{
369     $cust_pkg->set_usage(\%values, %opt);
370   }
371 }
372
373 1;