c52f96e1273a75bad9af08d89cf8c5b300b35fd0
[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     'unsuspend_adjust_bill' => 
139                         { 'name' => 'Adjust next bill date forward when '.
140                                     'unsuspending',
141                           'type' => 'checkbox',
142                         },
143
144     %usage_fields,
145     %usage_recharge_fields,
146
147     'externalid' => { 'name'   => 'Optional External ID',
148                       'default' => '',
149                     },
150   },
151   'fieldorder' => [ qw( setup_fee recur_fee
152                         recur_temporality unused_credit
153                         expire_months adjourn_months
154                         contract_end_months
155                         start_1st sync_bill_date
156                         unsuspend_adjust_bill
157                       ),
158                     @usage_fieldorder, @usage_recharge_fieldorder,
159                     qw( externalid ),
160                   ],
161   'weight' => 10,
162 );
163
164 sub calc_setup {
165   my($self, $cust_pkg, $sdate, $details ) = @_;
166
167   my $i = 0;
168   my $count = $self->option( 'additional_count', 'quiet' ) || 0;
169   while ($i < $count) {
170     push @$details, $self->option( 'additional_info' . $i++ );
171   }
172
173   my $quantity = $cust_pkg->quantity || 1;
174
175   sprintf("%.2f", $quantity * $self->unit_setup($cust_pkg, $sdate, $details) );
176 }
177
178 sub unit_setup {
179   my($self, $cust_pkg, $sdate, $details ) = @_;
180
181   $self->option('setup_fee') || 0;
182 }
183
184 sub calc_recur {
185   my $self = shift;
186   my($cust_pkg, $sdate, $details, $param ) = @_;
187
188   #my $last_bill = $cust_pkg->last_bill;
189   my $last_bill = $cust_pkg->get('last_bill'); #->last_bill falls back to setup
190
191   return 0
192     if $self->option('recur_temporality', 1) eq 'preceding' && $last_bill == 0;
193
194   if( $self->option('sync_bill_date',1) ) {
195     return $self->calc_prorate(@_);
196   }
197   else {
198     my $charge = $self->base_recur($cust_pkg);
199     $charge *= $param->{freq_override} if $param->{freq_override};
200     my $discount = $self->calc_discount($cust_pkg, $sdate, $details, $param);
201
202     return sprintf('%.2f', $charge - $discount);
203   }
204 }
205
206 sub calc_discount {
207   my($self, $cust_pkg, $sdate, $details, $param ) = @_;
208
209   my $br = $self->base_recur($cust_pkg);
210
211   my $tot_discount = 0;
212   #UI enforces just 1 for now, will need ordering when they can be stacked
213
214   if ( $param->{freq_override} ) {
215     my $real_part_pkg = new FS::part_pkg { $self->hash };
216     $real_part_pkg->pkgpart($param->{real_pkgpart} || $self->pkgpart);
217     my @discount = grep { $_->months == $param->{freq_override} }
218                    map { $_->discount }
219                    $real_part_pkg->part_pkg_discount;
220     my $discount = shift @discount;
221     $param->{months} = $param->{freq_override} unless $param->{months};
222     my $error;
223     if ($discount) {
224       if ($discount->months == $param->{months}) {
225         $cust_pkg->discountnum($discount->discountnum);
226         $error = $cust_pkg->insert_discount;
227       } else {
228         $cust_pkg->discountnum(-1);
229         foreach ( qw( amount percent months ) ) {
230           my $method = "discountnum_$_";
231           $cust_pkg->$method($discount->$_);
232         }
233         $error = $cust_pkg->insert_discount;
234       }
235       die "error discounting using part_pkg_discount: $error" if $error;
236     }
237   }
238
239   my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active;
240   foreach my $cust_pkg_discount ( @cust_pkg_discount ) {
241      my $discount = $cust_pkg_discount->discount;
242      #UI enforces one or the other (for now?  probably for good)
243      my $amount = 0;
244      $amount += $discount->amount
245        if $cust_pkg->pkgpart == $param->{real_pkgpart};
246      $amount += sprintf('%.2f', $discount->percent * $br / 100 );
247
248      my $chg_months = $param->{'months'} || $cust_pkg->part_pkg->freq;
249      
250      my $months = $discount->months
251                     ? min( $chg_months,
252                            $discount->months - $cust_pkg_discount->months_used )
253                     : $chg_months;
254
255      my $error = $cust_pkg_discount->increment_months_used($months)
256        if $cust_pkg->pkgpart == $param->{real_pkgpart};
257      die "error discounting: $error" if $error;
258
259      $amount *= $months;
260      $amount = sprintf('%.2f', $amount);
261
262      next unless $amount > 0;
263
264      #record details in cust_bill_pkg_discount
265      my $cust_bill_pkg_discount = new FS::cust_bill_pkg_discount {
266        'pkgdiscountnum' => $cust_pkg_discount->pkgdiscountnum,
267        'amount'         => $amount,
268        'months'         => $months,
269      };
270      push @{ $param->{'discounts'} }, $cust_bill_pkg_discount;
271
272      #add details on discount to invoice
273      my $conf = new FS::Conf;
274      my $money_char = $conf->config('money_char') || '$';  
275      $months = sprintf('%.2f', $months) if $months =~ /\./;
276
277      my $d = 'Includes ';
278      $d .= $discount->name. ' ' if $discount->name;
279      $d .= 'discount of '. $discount->description_short;
280      $d .= " for $months month". ( $months!=1 ? 's' : '' );
281      $d .= ": $money_char$amount" if $months != 1 || $discount->percent;
282      push @$details, $d;
283
284      $tot_discount += $amount;
285   }
286
287   sprintf('%.2f', $tot_discount);
288 }
289
290 sub base_recur {
291   my($self, $cust_pkg) = @_;
292   $self->option('recur_fee', 1) || 0;
293 }
294
295 sub base_recur_permonth {
296   my($self, $cust_pkg) = @_;
297
298   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
299
300   sprintf('%.2f', $self->base_recur($cust_pkg) / $self->freq );
301 }
302
303 sub calc_remain {
304   my ($self, $cust_pkg, %options) = @_;
305
306   my $time;
307   if ($options{'time'}) {
308     $time = $options{'time'};
309   } else {
310     $time = time;
311   }
312
313   my $next_bill = $cust_pkg->getfield('bill') || 0;
314
315   #my $last_bill = $cust_pkg->last_bill || 0;
316   my $last_bill = $cust_pkg->get('last_bill') || 0; #->last_bill falls back to setup
317
318   return 0 if    ! $self->base_recur($cust_pkg)
319               || ! $self->option('unused_credit', 1)
320               || ! $last_bill
321               || ! $next_bill
322               || $next_bill < $time;
323
324   my %sec = (
325     'h' =>    3600, # 60 * 60
326     'd' =>   86400, # 60 * 60 * 24
327     'w' =>  604800, # 60 * 60 * 24 * 7
328     'm' => 2629744, # 60 * 60 * 24 * 365.2422 / 12 
329   );
330
331   $self->freq =~ /^(\d+)([hdwm]?)$/
332     or die 'unparsable frequency: '. $self->freq;
333   my $freq_sec = $1 * $sec{$2||'m'};
334   return 0 unless $freq_sec;
335
336   sprintf("%.2f", $self->base_recur($cust_pkg) * ( $next_bill - $time ) / $freq_sec );
337
338 }
339
340 sub is_free_options {
341   qw( setup_fee recur_fee );
342 }
343
344 sub is_prepaid { 0; } #no, we're postpaid
345
346 #XXX discounts only on recurring fees for now (no setup/one-time or usage)
347 sub can_discount {
348   my $self = shift;
349   $self->freq =~ /^\d+$/ && $self->freq > 0;
350 }
351
352 sub usage_valuehash {
353   my $self = shift;
354   map { $_, $self->option($_) }
355     grep { $self->option($_, 'hush') } 
356     qw(seconds upbytes downbytes totalbytes);
357 }
358
359 sub reset_usage {
360   my($self, $cust_pkg, %opt) = @_;
361   warn "   resetting usage counters" if defined($opt{debug}) && $opt{debug} > 1;
362   my %values = $self->usage_valuehash;
363   if ($self->option('usage_rollover', 1)) {
364     $cust_pkg->recharge(\%values);
365   }else{
366     $cust_pkg->set_usage(\%values, %opt);
367   }
368 }
369
370 1;