non-package fees, "hold until next bill" option, #25899
[freeside.git] / FS / FS / part_fee.pm
1 package FS::part_fee;
2
3 use strict;
4 use base qw( FS::o2m_Common FS::Record );
5 use vars qw( $DEBUG );
6 use FS::Record qw( qsearch qsearchs );
7
8 $DEBUG = 1;
9
10 =head1 NAME
11
12 FS::part_fee - Object methods for part_fee records
13
14 =head1 SYNOPSIS
15
16   use FS::part_fee;
17
18   $record = new FS::part_fee \%hash;
19   $record = new FS::part_fee { 'column' => 'value' };
20
21   $error = $record->insert;
22
23   $error = $new_record->replace($old_record);
24
25   $error = $record->delete;
26
27   $error = $record->check;
28
29 =head1 DESCRIPTION
30
31 An FS::part_fee object represents the definition of a fee
32
33 Fees are like packages, but instead of being ordered and then billed on a 
34 cycle, they are created by the operation of events and added to a single
35 invoice.  The fee definition specifies the fee's description, how the amount
36 is calculated (a flat fee or a percentage of the customer's balance), and 
37 how to classify the fee for tax and reporting purposes.
38
39 FS::part_fee inherits from FS::Record.  The following fields are currently 
40 supported:
41
42 =over 4
43
44 =item feepart - primary key
45
46 =item comment - a description of the fee for employee use, not shown on 
47 the invoice
48
49 =item disabled - 'Y' if the fee is disabled
50
51 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
52
53 =item taxable - 'Y' if this fee should be considered a taxable sale.  
54 Currently, taxable fees will be treated like they exist at the customer's
55 default service location.
56
57 =item nextbill - 'Y' if this fee should be delayed until the customer is 
58 billed for a package.
59
60 =item taxclass - the tax class the fee belongs to, as a string, for the 
61 internal tax system
62
63 =item taxproductnum - the tax product family the fee belongs to, for the 
64 external tax system in use, if any
65
66 =item pay_weight - Weight (relative to credit_weight and other package/fee 
67 definitions) that controls payment application to specific line items.
68
69 =item credit_weight - Weight that controls credit application to specific
70 line items.
71
72 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
73
74 =item amount - the flat fee to charge, as a decimal amount
75
76 =item percent - the percentage of the base to charge (out of 100).  If both
77 this and "amount" are specified, the fee will be the sum of the two.
78
79 =item basis - the method for calculating the base: currently one of "charged",
80 "owed", or null.
81
82 =item minimum - the minimum fee that should be charged
83
84 =item maximum - the maximum fee that should be charged
85
86 =item limit_credit - 'Y' to set the maximum fee at the customer's credit 
87 balance, if any.
88
89 =item setuprecur - whether the fee should be classified as 'setup' or 
90 'recur', for reporting purposes.
91
92 =back
93
94 =head1 METHODS
95
96 =over 4
97
98 =item new HASHREF
99
100 Creates a new fee definition.  To add the record to the database, see 
101 L<"insert">.
102
103 =cut
104
105 sub table { 'part_fee'; }
106
107 =item insert
108
109 Adds this record to the database.  If there is an error, returns the error,
110 otherwise returns false.
111
112 =item delete
113
114 Delete this record from the database.
115
116 =item replace OLD_RECORD
117
118 Replaces the OLD_RECORD with this one in the database.  If there is an error,
119 returns the error, otherwise returns false.
120
121 =item check
122
123 Checks all fields to make sure this is a valid example.  If there is
124 an error, returns the error, otherwise returns false.  Called by the insert
125 and replace methods.
126
127 =cut
128
129 sub check {
130   my $self = shift;
131
132   $self->set('amount', 0) unless $self->amount;
133   $self->set('percent', 0) unless $self->percent;
134
135   my $error = 
136     $self->ut_numbern('feepart')
137     || $self->ut_textn('comment')
138     || $self->ut_flag('disabled')
139     || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
140     || $self->ut_flag('taxable')
141     || $self->ut_flag('nextbill')
142     || $self->ut_textn('taxclass')
143     || $self->ut_numbern('taxproductnum')
144     || $self->ut_floatn('pay_weight')
145     || $self->ut_floatn('credit_weight')
146     || $self->ut_agentnum_acl('agentnum',
147                               [ 'Edit global package definitions' ])
148     || $self->ut_money('amount')
149     || $self->ut_float('percent')
150     || $self->ut_moneyn('minimum')
151     || $self->ut_moneyn('maximum')
152     || $self->ut_flag('limit_credit')
153     || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
154     || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
155   ;
156   return $error if $error;
157
158   return "For a percentage fee, the basis must be set"
159     if $self->get('percent') > 0 and $self->get('basis') eq '';
160
161   if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
162     # then it makes no sense to apply minimum/maximum
163     $self->set('minimum', '');
164     $self->set('maximum', '');
165   }
166   if ( $self->get('limit_credit') ) {
167     $self->set('maximum', '');
168   }
169
170   $self->SUPER::check;
171 }
172
173 =item explanation
174
175 Returns a string describing how this fee is calculated.
176
177 =cut
178
179 sub explanation {
180   my $self = shift;
181   # XXX customer currency
182   my $money_char = FS::Conf->new->config('money_char') || '$';
183   my $money = $money_char . '%.2f';
184   my $percent = '%.1f%%';
185   my $string;
186   if ( $self->amount > 0 ) {
187     $string = sprintf($money, $self->amount);
188   }
189   if ( $self->percent > 0 ) {
190     if ( $string ) {
191       $string .= " plus ";
192     }
193     $string .= sprintf($percent, $self->percent);
194     $string .= ' of the ';
195     if ( $self->basis eq 'charged' ) {
196       $string .= 'invoice amount';
197     } elsif ( $self->basis('owed') ) {
198       $string .= 'unpaid invoice balance';
199     }
200   }
201   if ( $self->minimum or $self->maximum or $self->limit_credit ) {
202     $string .= "\nbut";
203     if ( $self->minimum ) {
204       $string .= ' at least '.sprintf($money, $self->minimum);
205     }
206     if ( $self->maximum ) {
207       $string .= ' and' if $self->minimum;
208       $string .= ' at most '.sprintf($money, $self->maximum);
209     }
210     if ( $self->limit_credit ) {
211       if ( $self->maximum ) {
212         $string .= ", or the customer's credit balance, whichever is less.";
213       } else {
214         $string .= ' and' if $self->minimum;
215         $string .= " not more than the customer's credit balance";
216       }
217     }
218   }
219   return $string;
220 }
221
222 =item lineitem INVOICE
223
224 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object 
225 representing the invoice line item for the fee, with linked 
226 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or 
227 its line items, as appropriate.
228
229 If the fee is going to be charged on the upcoming invoice (credit card 
230 processing fees, postal invoice fees), INVOICE should be an uninserted
231 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
232 of the non-fee line items that will appear on the invoice.
233
234 =cut
235
236 sub lineitem {
237   my $self = shift;
238   my $cust_bill = shift;
239   my $cust_main = $cust_bill->cust_main;
240
241   my $amount = 0 + $self->get('amount');
242   my $total_base;  # sum of base line items
243   my @items;       # base line items (cust_bill_pkg records)
244   my @item_base;   # charged/owed of that item (sequential w/ @items)
245   my @item_fee;    # fee amount of that item (sequential w/ @items)
246   my @cust_bill_pkg_fee; # link record
247
248   warn "Calculating fee: ".$self->itemdesc." on ".
249     ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
250     "\n" if $DEBUG;
251   if ( $self->percent > 0 and $self->basis ne '' ) {
252     warn $self->percent . "% of amount ".$self->basis.")\n"
253       if $DEBUG;
254
255     # $total_base: the total charged/owed on the invoice
256     # %item_base: billpkgnum => fraction of base amount
257     if ( $cust_bill->invnum ) {
258       my $basis = $self->basis;
259       $total_base = $cust_bill->$basis; # "charged", "owed"
260
261       # calculate the fee on an already-inserted past invoice.  This may have 
262       # payments or credits, so if basis = owed, we need to consider those.
263       my $basis_sql = $basis.'_sql';
264       my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
265                 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
266       @items = $cust_bill->cust_bill_pkg;
267       @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
268                     @items;
269     } else {
270       # the fee applies to _this_ invoice.  It has no payments or credits, so
271       # "charged" and "owed" basis are both just the invoice amount, and 
272       # the line item amounts (setup + recur)
273       $total_base = $cust_bill->charged;
274       @items = @{ $cust_bill->get('cust_bill_pkg') };
275       @item_base = map { $_->setup + $_->recur }
276                     @items;
277     }
278
279     $amount += $total_base * $self->percent / 100;
280   }
281
282   if ( $self->minimum ne '' and $amount < $self->minimum ) {
283     warn "Applying mininum fee\n" if $DEBUG;
284     $amount = $self->minimum;
285   }
286
287   my $maximum = $self->maximum;
288   if ( $self->limit_credit ) {
289     my $balance = $cust_bill->cust_main->balance;
290     if ( $balance >= 0 ) {
291       warn "Credit balance is zero, so fee is zero" if $DEBUG;
292       return; # don't bother doing estimated tax, etc.
293     } elsif ( -1 * $balance < $maximum ) {
294       $maximum = -1 * $balance;
295     }
296   }
297   if ( $maximum ne '' ) {
298     warn "Applying maximum fee\n" if $DEBUG;
299     $amount = $maximum;
300   }
301
302   # at this point, if the fee is zero, return nothing
303   return if $amount < 0.005;
304   $amount = sprintf('%.2f', $amount);
305
306   my $cust_bill_pkg = FS::cust_bill_pkg->new({
307       feepart     => $self->feepart,
308       pkgnum      => 0,
309       # no sdate/edate, right?
310       setup       => 0,
311       recur       => 0,
312   });
313
314   if ( $maximum and $self->taxable ) {
315     warn "Estimating taxes on fee.\n";
316     # then we need to estimate tax to respect the maximum
317     # XXX currently doesn't work with external (tax_rate) taxes
318     # or batch taxes, obviously
319     my $taxlisthash = {};
320     my $error = $cust_main->_handle_taxes(
321       $taxlisthash,
322       $cust_bill_pkg,
323       location => $cust_main->ship_location
324     );
325     my $total_rate = 0;
326     # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
327     my @taxes = map { $_->[0] } values %$taxlisthash;
328     foreach (@taxes) {
329       $total_rate += $_->tax;
330     }
331     if ($total_rate > 0) {
332       my $max_cents = $maximum * 100;
333       my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
334       $maximum = sprintf('%.2f', $charge_cents / 100.00);
335       $amount = $maximum if $amount > $maximum;
336     }
337   } # if $maximum and $self->taxable
338
339   # set the amount that we'll charge
340   $cust_bill_pkg->set( $self->setuprecur, $amount );
341
342   if ( $self->classnum ) {
343     my $pkg_category = $self->pkg_class->pkg_category;
344     $cust_bill_pkg->set('section' => $pkg_category->categoryname)
345       if $pkg_category;
346   }
347
348   # if this is a percentage fee and has line item fractions,
349   # adjust them to be proportional and to add up correctly.
350   if ( @item_base ) {
351     my $cents = $amount * 100;
352     # not necessarily the same as percent
353     my $multiplier = $amount / $total_base;
354     for (my $i = 0; $i < scalar(@items); $i++) {
355       my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
356       $item_fee[$i] = $fee;
357       $cents -= $fee * 100;
358     }
359     # correct rounding error
360     while ($cents >= 0.5 or $cents < -0.5) {
361       foreach my $fee (@item_fee) {
362         if ( $cents >= 0.5 ) {
363           $fee += 0.01;
364           $cents--;
365         } elsif ( $cents < -0.5 ) {
366           $fee -= 0.01;
367           $cents++;
368         }
369       }
370     }
371     # and add them to the cust_bill_pkg
372     for (my $i = 0; $i < scalar(@items); $i++) {
373       if ( $item_fee[$i] > 0 ) {
374         push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
375             cust_bill_pkg   => $cust_bill_pkg,
376             base_invnum     => $cust_bill->invnum,
377             amount          => $item_fee[$i],
378             base_cust_bill_pkg => $items[$i], # late resolve
379         });
380       }
381     }
382   } else { # if !@item_base
383     # then this isn't a proportional fee, so it just applies to the 
384     # entire invoice.
385     # (if it's the current invoice, $cust_bill->invnum is null and that 
386     # will be fixed later)
387     push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
388         cust_bill_pkg   => $cust_bill_pkg,
389         base_invnum     => $cust_bill->invnum,
390         amount          => $amount,
391     });
392   }
393
394   # cust_bill_pkg::insert will handle this
395   $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
396   # avoid misbehavior by usage() and some other things
397   $cust_bill_pkg->set('details', []);
398
399   return $cust_bill_pkg;
400 }
401
402 =item itemdesc_locale LOCALE
403
404 Returns a customer-viewable description of this fee for the given locale,
405 from the part_fee_msgcat table.  If the locale is empty or no localized fee
406 description exists, returns part_fee.itemdesc.
407
408 =cut
409
410 sub itemdesc_locale {
411   my ( $self, $locale ) = @_;
412   return $self->itemdesc unless $locale;
413   my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
414     feepart => $self->feepart,
415     locale  => $locale,
416   }) or return $self->itemdesc;
417   $part_fee_msgcat->itemdesc;
418 }
419
420 =item tax_rates DATA_PROVIDER, GEOCODE
421
422 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
423 fee, in the location specified by GEOCODE.
424
425 =cut
426
427 sub tax_rates {
428   my $self = shift;
429   my ($vendor, $geocode) = @_;
430   return unless $self->taxproductnum;
431   my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
432   # cch stuff
433   my @taxclassnums = map { $_->taxclassnum }
434                      $taxproduct->part_pkg_taxrate($geocode);
435   return unless @taxclassnums;
436
437   warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
438   if $DEBUG;
439   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
440   my @taxes = qsearch({ 'table'     => 'tax_rate',
441       'hashref'   => { 'geocode'     => $geocode,
442         'data_vendor' => $vendor },
443       'extra_sql' => $extra_sql,
444     });
445   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
446   if $DEBUG;
447
448   return @taxes;
449 }
450
451 sub part_pkg_taxoverride {} # we don't do overrides here
452
453 sub has_taxproduct {
454   my $self = shift;
455   return ($self->taxproductnum ? 1 : 0);
456 }
457
458 =back
459
460 =head1 BUGS
461
462 =head1 SEE ALSO
463
464 L<FS::Record>
465
466 =cut
467
468 1;
469