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