non-package fees, phase 1, #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 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   my $error = 
130     $self->ut_numbern('feepart')
131     || $self->ut_textn('comment')
132     || $self->ut_flag('disabled')
133     || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
134     || $self->ut_flag('taxable')
135     || $self->ut_textn('taxclass')
136     || $self->ut_numbern('taxproductnum')
137     || $self->ut_floatn('pay_weight')
138     || $self->ut_floatn('credit_weight')
139     || $self->ut_agentnum_acl('agentnum',
140                               [ 'Edit global package definitions' ])
141     || $self->ut_moneyn('amount')
142     || $self->ut_floatn('percent')
143     || $self->ut_moneyn('minimum')
144     || $self->ut_moneyn('maximum')
145     || $self->ut_flag('limit_credit')
146     || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
147     || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
148   ;
149   return $error if $error;
150
151   return "For a percentage fee, the basis must be set"
152     if $self->get('percent') > 0 and $self->get('basis') eq '';
153
154   if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
155     # then it makes no sense to apply minimum/maximum
156     $self->set('minimum', '');
157     $self->set('maximum', '');
158   }
159   if ( $self->get('limit_credit') ) {
160     $self->set('maximum', '');
161   }
162
163   $self->SUPER::check;
164 }
165
166 =item explanation
167
168 Returns a string describing how this fee is calculated.
169
170 =cut
171
172 sub explanation {
173   my $self = shift;
174   # XXX customer currency
175   my $money_char = FS::Conf->new->config('money_char') || '$';
176   my $money = $money_char . '%.2f';
177   my $percent = '%.1f%%';
178   my $string;
179   if ( $self->amount > 0 ) {
180     $string = sprintf($money, $self->amount);
181   }
182   if ( $self->percent > 0 ) {
183     if ( $string ) {
184       $string .= " plus ";
185     }
186     $string .= sprintf($percent, $self->percent);
187     $string .= ' of the ';
188     if ( $self->basis eq 'charged' ) {
189       $string .= 'invoice amount';
190     } elsif ( $self->basis('owed') ) {
191       $string .= 'unpaid invoice balance';
192     }
193   }
194   if ( $self->minimum or $self->maximum or $self->limit_credit ) {
195     $string .= "\nbut";
196     if ( $self->minimum ) {
197       $string .= ' at least '.sprintf($money, $self->minimum);
198     }
199     if ( $self->maximum ) {
200       $string .= ' and' if $self->minimum;
201       $string .= ' at most '.sprintf($money, $self->maximum);
202     }
203     if ( $self->limit_credit ) {
204       if ( $self->maximum ) {
205         $string .= ", or the customer's credit balance, whichever is less.";
206       } else {
207         $string .= ' and' if $self->minimum;
208         $string .= " not more than the customer's credit balance";
209       }
210     }
211   }
212   return $string;
213 }
214
215 =item lineitem INVOICE
216
217 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object 
218 representing the invoice line item for the fee, with linked 
219 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or 
220 its line items, as appropriate.
221
222 =cut
223
224 sub lineitem {
225   my $self = shift;
226   my $cust_bill = shift;
227
228   my $amount = 0 + $self->get('amount');
229   my $total_base;  # sum of base line items
230   my @items;       # base line items (cust_bill_pkg records)
231   my @item_base;   # charged/owed of that item (sequential w/ @items)
232   my @item_fee;    # fee amount of that item (sequential w/ @items)
233   my @cust_bill_pkg_fee; # link record
234
235   warn "Calculating fee: ".$self->itemdesc." on ".
236     ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
237     "\n" if $DEBUG;
238   if ( $self->percent > 0 and $self->basis ne '' ) {
239     warn $self->percent . "% of amount ".$self->basis.")\n"
240       if $DEBUG;
241
242     # $total_base: the total charged/owed on the invoice
243     # %item_base: billpkgnum => fraction of base amount
244     if ( $cust_bill->invnum ) {
245       my $basis = $self->basis;
246       $total_base = $cust_bill->$basis; # "charged", "owed"
247
248       # calculate the fee on an already-inserted past invoice.  This may have 
249       # payments or credits, so if basis = owed, we need to consider those.
250       my $basis_sql = $basis.'_sql';
251       my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
252                 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
253       @items = $cust_bill->cust_bill_pkg;
254       @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
255                     @items;
256     } else {
257       # the fee applies to _this_ invoice.  It has no payments or credits, so
258       # "charged" and "owed" basis are both just the invoice amount, and 
259       # the line item amounts (setup + recur)
260       $total_base = $cust_bill->charged;
261       @items = @{ $cust_bill->get('cust_bill_pkg') };
262       @item_base = map { $_->setup + $_->recur }
263                     @items;
264     }
265
266     $amount += $total_base * $self->percent / 100;
267   }
268
269   if ( $self->minimum ne '' and $amount < $self->minimum ) {
270     warn "Applying mininum fee\n" if $DEBUG;
271     $amount = $self->minimum;
272   }
273
274   my $maximum = $self->maximum;
275   if ( $self->limit_credit ) {
276     my $balance = $cust_bill->cust_main;
277     if ( $balance >= 0 ) {
278       $maximum = 0;
279     } elsif ( -1 * $balance < $maximum ) {
280       $maximum = -1 * $balance;
281     }
282   }
283   if ( $maximum ne '' and $amount > $maximum ) {
284     warn "Applying maximum fee\n" if $DEBUG;
285     $amount = $maximum;
286   }
287
288   # at this point, if the fee is zero, return nothing
289   return if $amount < 0.005;
290   $amount = sprintf('%.2f', $amount);
291
292   my $cust_bill_pkg = FS::cust_bill_pkg->new({
293       feepart     => $self->feepart,
294       pkgnum      => 0,
295       # no sdate/edate, right?
296       setup       => 0,
297       recur       => 0,
298   });
299   $cust_bill_pkg->set( $self->setuprecur, $amount );
300   
301   if ( $self->classnum ) {
302     my $pkg_category = $self->pkg_class->pkg_category;
303     $cust_bill_pkg->set('section' => $pkg_category->categoryname)
304       if $pkg_category;
305   }
306
307   # if this is a percentage fee and has line item fractions,
308   # adjust them to be proportional and to add up correctly.
309   if ( @item_base ) {
310     my $cents = $amount * 100;
311     # not necessarily the same as percent
312     my $multiplier = $amount / $total_base;
313     for (my $i = 0; $i < scalar(@items); $i++) {
314       my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
315       $item_fee[$i] = $fee;
316       $cents -= $fee * 100;
317     }
318     # correct rounding error
319     while ($cents >= 0.5 or $cents < -0.5) {
320       foreach my $fee (@item_fee) {
321         if ( $cents >= 0.5 ) {
322           $fee += 0.01;
323           $cents--;
324         } elsif ( $cents < -0.5 ) {
325           $fee -= 0.01;
326           $cents++;
327         }
328       }
329     }
330     # and add them to the cust_bill_pkg
331     for (my $i = 0; $i < scalar(@items); $i++) {
332       if ( $item_fee[$i] > 0 ) {
333         push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
334             cust_bill_pkg   => $cust_bill_pkg,
335             base_invnum     => $cust_bill->invnum,
336             amount          => $item_fee[$i],
337             base_cust_bill_pkg => $items[$i], # late resolve
338         });
339       }
340     }
341   } else { # if !@item_base
342     # then this isn't a proportional fee, so it just applies to the 
343     # entire invoice.
344     # (if it's the current invoice, $cust_bill->invnum is null and that 
345     # will be fixed later)
346     push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
347         cust_bill_pkg   => $cust_bill_pkg,
348         base_invnum     => $cust_bill->invnum,
349         amount          => $amount,
350     });
351   }
352
353   # cust_bill_pkg::insert will handle this
354   $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
355   # avoid misbehavior by usage() and some other things
356   $cust_bill_pkg->set('details', []);
357
358   return $cust_bill_pkg;
359 }
360
361 =item itemdesc_locale LOCALE
362
363 Returns a customer-viewable description of this fee for the given locale,
364 from the part_fee_msgcat table.  If the locale is empty or no localized fee
365 description exists, returns part_fee.itemdesc.
366
367 =cut
368
369 sub itemdesc_locale {
370   my ( $self, $locale ) = @_;
371   return $self->itemdesc unless $locale;
372   my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
373     feepart => $self->feepart,
374     locale  => $locale,
375   }) or return $self->itemdesc;
376   $part_fee_msgcat->itemdesc;
377 }
378
379 =item tax_rates DATA_PROVIDER, GEOCODE
380
381 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
382 fee, in the location specified by GEOCODE.
383
384 =cut
385
386 sub tax_rates {
387   my $self = shift;
388   my ($vendor, $geocode) = @_;
389   return unless $self->taxproductnum;
390   my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
391   # cch stuff
392   my @taxclassnums = map { $_->taxclassnum }
393                      $taxproduct->part_pkg_taxrate($geocode);
394   return unless @taxclassnums;
395
396   warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
397   if $DEBUG;
398   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
399   my @taxes = qsearch({ 'table'     => 'tax_rate',
400       'hashref'   => { 'geocode'     => $geocode,
401         'data_vendor' => $vendor },
402       'extra_sql' => $extra_sql,
403     });
404   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
405   if $DEBUG;
406
407   return @taxes;
408 }
409
410 sub part_pkg_taxoverride {} # we don't do overrides here
411
412 sub has_taxproduct {
413   my $self = shift;
414   return ($self->taxproductnum ? 1 : 0);
415 }
416
417 =back
418
419 =head1 BUGS
420
421 =head1 SEE ALSO
422
423 L<FS::Record>
424
425 =cut
426
427 1;
428