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