4 use base qw( FS::o2m_Common FS::Record );
6 use FS::Record qw( qsearch qsearchs );
8 use FS::cust_bill_pkg_display;
9 use FS::part_pkg_taxproduct;
11 use FS::part_fee_usage;
17 FS::part_fee - Object methods for part_fee records
23 $record = new FS::part_fee \%hash;
24 $record = new FS::part_fee { 'column' => 'value' };
26 $error = $record->insert;
28 $error = $new_record->replace($old_record);
30 $error = $record->delete;
32 $error = $record->check;
36 An FS::part_fee object represents the definition of a fee
38 Fees are like packages, but instead of being ordered and then billed on a
39 cycle, they are created by the operation of events and added to a single
40 invoice. The fee definition specifies the fee's description, how the amount
41 is calculated (a flat fee or a percentage of the customer's balance), and
42 how to classify the fee for tax and reporting purposes.
44 FS::part_fee inherits from FS::Record. The following fields are currently
49 =item feepart - primary key
51 =item comment - a description of the fee for employee use, not shown on
54 =item disabled - 'Y' if the fee is disabled
56 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
58 =item taxable - 'Y' if this fee should be considered a taxable sale.
59 Currently, taxable fees will be treated like they exist at the customer's
60 default service location.
62 =item taxclass - the tax class the fee belongs to, as a string, for the
65 =item taxproductnum - the tax product family the fee belongs to, for the
66 external tax system in use, if any
68 =item pay_weight - Weight (relative to credit_weight and other package/fee
69 definitions) that controls payment application to specific line items.
71 =item credit_weight - Weight that controls credit application to specific
74 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
76 =item amount - the flat fee to charge, as a decimal amount
78 =item percent - the percentage of the base to charge (out of 100). If both
79 this and "amount" are specified, the fee will be the sum of the two.
81 =item basis - the method for calculating the base: currently one of "charged",
84 =item minimum - the minimum fee that should be charged
86 =item maximum - the maximum fee that should be charged
88 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
91 =item setuprecur - whether the fee should be classified as 'setup' or
92 'recur', for reporting purposes.
102 Creates a new fee definition. To add the record to the database, see
107 sub table { 'part_fee'; }
111 Adds this record to the database. If there is an error, returns the error,
112 otherwise returns false.
116 Delete this record from the database.
118 =item replace OLD_RECORD
120 Replaces the OLD_RECORD with this one in the database. If there is an error,
121 returns the error, otherwise returns false.
125 Checks all fields to make sure this is a valid example. If there is
126 an error, returns the error, otherwise returns false. Called by the insert
134 $self->set('amount', 0) unless $self->amount;
135 $self->set('percent', 0) unless $self->percent;
138 $self->ut_numbern('feepart')
139 || $self->ut_textn('comment')
140 || $self->ut_flag('disabled')
141 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
142 || $self->ut_flag('taxable')
143 || $self->ut_textn('taxclass')
144 || $self->ut_numbern('taxproductnum')
145 || $self->ut_floatn('pay_weight')
146 || $self->ut_floatn('credit_weight')
147 || $self->ut_agentnum_acl('agentnum',
148 [ 'Edit global package definitions' ])
149 || $self->ut_money('amount')
150 || $self->ut_float('percent')
151 || $self->ut_moneyn('minimum')
152 || $self->ut_moneyn('maximum')
153 || $self->ut_flag('limit_credit')
154 || $self->ut_enum('basis', [ 'charged', 'owed', 'usage' ])
155 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
157 return $error if $error;
159 if ( $self->get('limit_credit') ) {
160 $self->set('maximum', '');
163 if ( $self->get('basis') eq 'usage' ) {
164 # to avoid confusion, don't also allow charging a percentage
165 $self->set('percent', 0);
173 Returns a string describing how this fee is calculated.
179 # XXX customer currency
180 my $money_char = FS::Conf->new->config('money_char') || '$';
181 my $money = $money_char . '%.2f';
182 my $percent = '%.1f%%';
184 if ( $self->amount > 0 ) {
185 $string = sprintf($money, $self->amount);
187 if ( $self->percent > 0 ) {
191 $string .= sprintf($percent, $self->percent);
192 $string .= ' of the ';
193 if ( $self->basis eq 'charged' ) {
194 $string .= 'invoice amount';
195 } elsif ( $self->basis('owed') ) {
196 $string .= 'unpaid invoice balance';
198 } elsif ( $self->basis eq 'usage' ) {
200 $string .= " plus \n";
202 # append per-class descriptions
203 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
206 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
208 if ( $self->minimum ) {
209 $string .= ' at least '.sprintf($money, $self->minimum);
211 if ( $self->maximum ) {
212 $string .= ' and' if $self->minimum;
213 $string .= ' at most '.sprintf($money, $self->maximum);
215 if ( $self->limit_credit ) {
216 if ( $self->maximum ) {
217 $string .= ", or the customer's credit balance, whichever is less.";
219 $string .= ' and' if $self->minimum;
220 $string .= " not more than the customer's credit balance";
227 =item lineitem INVOICE
229 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
230 representing the invoice line item for the fee, with linked
231 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
232 its line items, as appropriate.
234 If the fee is going to be charged on the upcoming invoice (credit card
235 processing fees, postal invoice fees), INVOICE should be an uninserted
236 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
237 of the non-fee line items that will appear on the invoice.
243 my $cust_bill = shift;
244 my $cust_main = $cust_bill->cust_main;
246 my $amount = 0 + $self->get('amount');
247 my $total_base; # sum of base line items
248 my @items; # base line items (cust_bill_pkg records)
249 my @item_base; # charged/owed of that item (sequential w/ @items)
250 my @item_fee; # fee amount of that item (sequential w/ @items)
251 my @cust_bill_pkg_fee; # link record
253 warn "Calculating fee: ".$self->itemdesc." on ".
254 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
256 my $basis = $self->basis;
258 # $total_base: the total charged/owed on the invoice
259 # %item_base: billpkgnum => fraction of base amount
260 if ( $cust_bill->invnum ) {
262 # calculate the fee on an already-inserted past invoice. This may have
263 # payments or credits, so if basis = owed, we need to consider those.
264 @items = $cust_bill->cust_bill_pkg;
265 if ( $basis ne 'usage' ) {
267 $total_base = $cust_bill->$basis; # "charged", "owed"
268 my $basis_sql = $basis.'_sql';
269 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
270 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
271 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
274 $amount += $total_base * $self->percent / 100;
277 # the fee applies to _this_ invoice. It has no payments or credits, so
278 # "charged" and "owed" basis are both just the invoice amount, and
279 # the line item amounts (setup + recur)
280 @items = @{ $cust_bill->get('cust_bill_pkg') };
281 if ( $basis ne 'usage' ) {
282 $total_base = $cust_bill->charged;
283 @item_base = map { $_->setup + $_->recur }
286 $amount += $total_base * $self->percent / 100;
290 if ( $basis eq 'usage' ) {
292 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
294 foreach my $item (@items) { # cust_bill_pkg objects
296 $item->regularize_details;
298 if ( $item->billpkgnum ) {
300 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
303 $details = $item->get('details') || [];
305 foreach my $d (@$details) {
306 # if there's a usage fee defined for this class...
307 next if $d->amount eq '' # not a real usage detail
308 or $d->amount == 0 # zero charge, probably shouldn't charge fee
310 my $p = $part_fee_usage{$d->classnum} or next;
311 $usage_fee += ($d->amount * $p->percent / 100)
313 # we'd create detail records here if we were doing that
315 # bypass @item_base entirely
316 push @item_fee, $usage_fee;
317 $amount += $usage_fee;
320 } # if $basis eq 'usage'
322 if ( $self->minimum ne '' and $amount < $self->minimum ) {
323 warn "Applying mininum fee\n" if $DEBUG;
324 $amount = $self->minimum;
327 my $maximum = $self->maximum;
328 if ( $self->limit_credit ) {
329 my $balance = $cust_bill->cust_main->balance;
330 if ( $balance >= 0 ) {
331 warn "Credit balance is zero, so fee is zero" if $DEBUG;
332 return; # don't bother doing estimated tax, etc.
333 } elsif ( -1 * $balance < $maximum ) {
334 $maximum = -1 * $balance;
337 if ( $maximum ne '' and $amount > $maximum ) {
338 warn "Applying maximum fee\n" if $DEBUG;
342 # at this point, if the fee is zero, return nothing
343 return if $amount < 0.005;
344 $amount = sprintf('%.2f', $amount);
346 my $cust_bill_pkg = FS::cust_bill_pkg->new({
347 feepart => $self->feepart,
349 # no sdate/edate, right?
354 if ( $maximum and $self->taxable ) {
355 warn "Estimating taxes on fee.\n" if $DEBUG;
356 # then we need to estimate tax to respect the maximum
357 # XXX currently doesn't work with external (tax_rate) taxes
358 # or batch taxes, obviously
359 my $taxlisthash = {};
360 my $error = $cust_main->_handle_taxes(
363 location => $cust_main->ship_location
366 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
367 my @taxes = map { $_->[0] } values %$taxlisthash;
369 $total_rate += $_->tax;
371 if ($total_rate > 0) {
372 my $max_cents = $maximum * 100;
373 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
374 # the actual maximum that we can charge...
375 $maximum = sprintf('%.2f', $charge_cents / 100.00);
376 $amount = $maximum if $amount > $maximum;
378 } # if $maximum and $self->taxable
380 # set the amount that we'll charge
381 $cust_bill_pkg->set( $self->setuprecur, $amount );
383 # create display record
384 my $categoryname = '';
385 if ( $self->classnum ) {
386 my $pkg_category = $self->pkg_class->pkg_category;
387 $categoryname = $pkg_category->categoryname if $pkg_category;
389 my $displaytype = ($self->setuprecur eq 'setup') ? 'S' : 'R';
390 my $display = FS::cust_bill_pkg_display->new({
391 type => $displaytype,
392 section => $categoryname,
393 # post_total? summary? who the hell knows?
395 $cust_bill_pkg->set('display', [ $display ]);
397 # if this is a percentage fee and has line item fractions,
398 # adjust them to be proportional and to add up correctly.
400 my $cents = $amount * 100;
401 # not necessarily the same as percent
402 my $multiplier = $amount / $total_base;
403 for (my $i = 0; $i < scalar(@items); $i++) {
404 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
405 $item_fee[$i] = $fee;
406 $cents -= $fee * 100;
408 # correct rounding error
409 while ($cents >= 0.5 or $cents < -0.5) {
410 foreach my $fee (@item_fee) {
411 if ( $cents >= 0.5 ) {
414 } elsif ( $cents < -0.5 ) {
422 # add allocation records to the cust_bill_pkg
423 for (my $i = 0; $i < scalar(@items); $i++) {
424 if ( $item_fee[$i] > 0 ) {
425 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
426 cust_bill_pkg => $cust_bill_pkg,
427 base_invnum => $cust_bill->invnum, # may be null
428 amount => $item_fee[$i],
429 base_cust_bill_pkg => $items[$i], # late resolve
433 } else { # if !@item_fee
434 # then this isn't a proportional fee, so it just applies to the
436 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
437 cust_bill_pkg => $cust_bill_pkg,
438 base_invnum => $cust_bill->invnum, # may be null
443 # cust_bill_pkg::insert will handle this
444 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
445 # avoid misbehavior by usage() and some other things
446 $cust_bill_pkg->set('details', []);
448 return $cust_bill_pkg;
451 =item itemdesc_locale LOCALE
453 Returns a customer-viewable description of this fee for the given locale,
454 from the part_fee_msgcat table. If the locale is empty or no localized fee
455 description exists, returns part_fee.itemdesc.
459 sub itemdesc_locale {
460 my ( $self, $locale ) = @_;
461 return $self->itemdesc unless $locale;
462 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
463 feepart => $self->feepart,
465 }) or return $self->itemdesc;
466 $part_fee_msgcat->itemdesc;
469 =item tax_rates DATA_PROVIDER, GEOCODE
471 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
472 fee, in the location specified by GEOCODE.
478 my ($vendor, $geocode) = @_;
479 return unless $self->taxproductnum;
480 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
482 my @taxclassnums = map { $_->taxclassnum }
483 $taxproduct->part_pkg_taxrate($geocode);
484 return unless @taxclassnums;
486 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
488 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
489 my @taxes = qsearch({ 'table' => 'tax_rate',
490 'hashref' => { 'geocode' => $geocode,
491 'data_vendor' => $vendor },
492 'extra_sql' => $extra_sql,
494 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
502 Returns the package category name, or the empty string if there is no package
509 my $pkg_class = $self->pkg_class;
510 $pkg_class ? $pkg_class->categoryname : '';
513 sub part_pkg_taxoverride {} # we don't do overrides here
517 return ($self->taxproductnum ? 1 : 0);
520 # stubs that will go away under 4.x
525 ? FS::pkg_class->by_key($self->classnum)
529 sub part_pkg_taxproduct {
532 ? FS::part_pkg_taxproduct->by_key($self->taxproductnum)
539 ? FS::agent->by_key($self->agentnum)
543 sub part_fee_msgcat {
545 qsearch( 'part_fee_msgcat', { feepart => $self->feepart } );
550 qsearch( 'part_fee_usage', { feepart => $self->feepart } );