4 use base qw( FS::o2m_Common FS::Record );
6 use FS::Record qw( qsearch qsearchs );
8 use FS::part_pkg_taxproduct;
10 use FS::part_fee_usage;
16 FS::part_fee - Object methods for part_fee records
22 $record = new FS::part_fee \%hash;
23 $record = new FS::part_fee { 'column' => 'value' };
25 $error = $record->insert;
27 $error = $new_record->replace($old_record);
29 $error = $record->delete;
31 $error = $record->check;
35 An FS::part_fee object represents the definition of a fee
37 Fees are like packages, but instead of being ordered and then billed on a
38 cycle, they are created by the operation of events and added to a single
39 invoice. The fee definition specifies the fee's description, how the amount
40 is calculated (a flat fee or a percentage of the customer's balance), and
41 how to classify the fee for tax and reporting purposes.
43 FS::part_fee inherits from FS::Record. The following fields are currently
48 =item feepart - primary key
50 =item comment - a description of the fee for employee use, not shown on
53 =item disabled - 'Y' if the fee is disabled
55 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
57 =item taxable - 'Y' if this fee should be considered a taxable sale.
58 Currently, taxable fees will be treated like they exist at the customer's
59 default service location.
61 =item taxclass - the tax class the fee belongs to, as a string, for the
64 =item taxproductnum - the tax product family the fee belongs to, for the
65 external tax system in use, if any
67 =item pay_weight - Weight (relative to credit_weight and other package/fee
68 definitions) that controls payment application to specific line items.
70 =item credit_weight - Weight that controls credit application to specific
73 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
75 =item amount - the flat fee to charge, as a decimal amount
77 =item percent - the percentage of the base to charge (out of 100). If both
78 this and "amount" are specified, the fee will be the sum of the two.
80 =item basis - the method for calculating the base: currently one of "charged",
83 =item minimum - the minimum fee that should be charged
85 =item maximum - the maximum fee that should be charged
87 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
90 =item setuprecur - whether the fee should be classified as 'setup' or
91 'recur', for reporting purposes.
101 Creates a new fee definition. To add the record to the database, see
106 sub table { 'part_fee'; }
110 Adds this record to the database. If there is an error, returns the error,
111 otherwise returns false.
115 Delete this record from the database.
117 =item replace OLD_RECORD
119 Replaces the OLD_RECORD with this one in the database. If there is an error,
120 returns the error, otherwise returns false.
124 Checks all fields to make sure this is a valid example. If there is
125 an error, returns the error, otherwise returns false. Called by the insert
133 $self->set('amount', 0) unless $self->amount;
134 $self->set('percent', 0) unless $self->percent;
137 $self->ut_numbern('feepart')
138 || $self->ut_textn('comment')
139 || $self->ut_flag('disabled')
140 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
141 || $self->ut_flag('taxable')
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', 'usage' ])
154 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
156 return $error if $error;
158 if ( $self->get('limit_credit') ) {
159 $self->set('maximum', '');
162 if ( $self->get('basis') eq 'usage' ) {
163 # to avoid confusion, don't also allow charging a percentage
164 $self->set('percent', 0);
172 Returns a string describing how this fee is calculated.
178 # XXX customer currency
179 my $money_char = FS::Conf->new->config('money_char') || '$';
180 my $money = $money_char . '%.2f';
181 my $percent = '%.1f%%';
183 if ( $self->amount > 0 ) {
184 $string = sprintf($money, $self->amount);
186 if ( $self->percent > 0 ) {
190 $string .= sprintf($percent, $self->percent);
191 $string .= ' of the ';
192 if ( $self->basis eq 'charged' ) {
193 $string .= 'invoice amount';
194 } elsif ( $self->basis('owed') ) {
195 $string .= 'unpaid invoice balance';
197 } elsif ( $self->basis eq 'usage' ) {
199 $string .= " plus \n";
201 # append per-class descriptions
202 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
205 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
207 if ( $self->minimum ) {
208 $string .= ' at least '.sprintf($money, $self->minimum);
210 if ( $self->maximum ) {
211 $string .= ' and' if $self->minimum;
212 $string .= ' at most '.sprintf($money, $self->maximum);
214 if ( $self->limit_credit ) {
215 if ( $self->maximum ) {
216 $string .= ", or the customer's credit balance, whichever is less.";
218 $string .= ' and' if $self->minimum;
219 $string .= " not more than the customer's credit balance";
226 =item lineitem INVOICE
228 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
229 representing the invoice line item for the fee, with linked
230 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
231 its line items, as appropriate.
233 If the fee is going to be charged on the upcoming invoice (credit card
234 processing fees, postal invoice fees), INVOICE should be an uninserted
235 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
236 of the non-fee line items that will appear on the invoice.
242 my $cust_bill = shift;
243 my $cust_main = $cust_bill->cust_main;
245 my $amount = 0 + $self->get('amount');
246 my $total_base; # sum of base line items
247 my @items; # base line items (cust_bill_pkg records)
248 my @item_base; # charged/owed of that item (sequential w/ @items)
249 my @item_fee; # fee amount of that item (sequential w/ @items)
250 my @cust_bill_pkg_fee; # link record
252 warn "Calculating fee: ".$self->itemdesc." on ".
253 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
255 my $basis = $self->basis;
257 # $total_base: the total charged/owed on the invoice
258 # %item_base: billpkgnum => fraction of base amount
259 if ( $cust_bill->invnum ) {
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 @items = $cust_bill->cust_bill_pkg;
264 if ( $basis ne 'usage' ) {
266 $total_base = $cust_bill->$basis; # "charged", "owed"
267 my $basis_sql = $basis.'_sql';
268 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
269 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
270 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
273 $amount += $total_base * $self->percent / 100;
276 # the fee applies to _this_ invoice. It has no payments or credits, so
277 # "charged" and "owed" basis are both just the invoice amount, and
278 # the line item amounts (setup + recur)
279 @items = @{ $cust_bill->get('cust_bill_pkg') };
280 if ( $basis ne 'usage' ) {
281 $total_base = $cust_bill->charged;
282 @item_base = map { $_->setup + $_->recur }
285 $amount += $total_base * $self->percent / 100;
289 if ( $basis eq 'usage' ) {
291 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
293 foreach my $item (@items) { # cust_bill_pkg objects
295 $item->regularize_details;
297 if ( $item->billpkgnum ) {
299 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
302 $details = $item->get('details') || [];
304 foreach my $d (@$details) {
305 # if there's a usage fee defined for this class...
306 next if $d->amount eq '' # not a real usage detail
307 or $d->amount == 0 # zero charge, probably shouldn't charge fee
309 my $p = $part_fee_usage{$d->classnum} or next;
310 $usage_fee += ($d->amount * $p->percent / 100)
312 # we'd create detail records here if we were doing that
314 # bypass @item_base entirely
315 push @item_fee, $usage_fee;
316 $amount += $usage_fee;
319 } # if $basis eq 'usage'
321 if ( $self->minimum ne '' and $amount < $self->minimum ) {
322 warn "Applying mininum fee\n" if $DEBUG;
323 $amount = $self->minimum;
326 my $maximum = $self->maximum;
327 if ( $self->limit_credit ) {
328 my $balance = $cust_bill->cust_main->balance;
329 if ( $balance >= 0 ) {
330 warn "Credit balance is zero, so fee is zero" if $DEBUG;
331 return; # don't bother doing estimated tax, etc.
332 } elsif ( -1 * $balance < $maximum ) {
333 $maximum = -1 * $balance;
336 if ( $maximum ne '' and $amount > $maximum ) {
337 warn "Applying maximum fee\n" if $DEBUG;
341 # at this point, if the fee is zero, return nothing
342 return if $amount < 0.005;
343 $amount = sprintf('%.2f', $amount);
345 my $cust_bill_pkg = FS::cust_bill_pkg->new({
346 feepart => $self->feepart,
348 # no sdate/edate, right?
353 if ( $maximum and $self->taxable ) {
354 warn "Estimating taxes on fee.\n" if $DEBUG;
355 # then we need to estimate tax to respect the maximum
356 # XXX currently doesn't work with external (tax_rate) taxes
357 # or batch taxes, obviously
358 my $taxlisthash = {};
359 my $error = $cust_main->_handle_taxes(
362 location => $cust_main->ship_location
365 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
366 my @taxes = map { $_->[0] } values %$taxlisthash;
368 $total_rate += $_->tax;
370 if ($total_rate > 0) {
371 my $max_cents = $maximum * 100;
372 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
373 # the actual maximum that we can charge...
374 $maximum = sprintf('%.2f', $charge_cents / 100.00);
375 $amount = $maximum if $amount > $maximum;
377 } # if $maximum and $self->taxable
379 # set the amount that we'll charge
380 $cust_bill_pkg->set( $self->setuprecur, $amount );
382 if ( $self->classnum ) {
383 my $pkg_category = $self->pkg_class->pkg_category;
384 $cust_bill_pkg->set('section' => $pkg_category->categoryname)
388 # if this is a percentage fee and has line item fractions,
389 # adjust them to be proportional and to add up correctly.
391 my $cents = $amount * 100;
392 # not necessarily the same as percent
393 my $multiplier = $amount / $total_base;
394 for (my $i = 0; $i < scalar(@items); $i++) {
395 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
396 $item_fee[$i] = $fee;
397 $cents -= $fee * 100;
399 # correct rounding error
400 while ($cents >= 0.5 or $cents < -0.5) {
401 foreach my $fee (@item_fee) {
402 if ( $cents >= 0.5 ) {
405 } elsif ( $cents < -0.5 ) {
413 # add allocation records to the cust_bill_pkg
414 for (my $i = 0; $i < scalar(@items); $i++) {
415 if ( $item_fee[$i] > 0 ) {
416 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
417 cust_bill_pkg => $cust_bill_pkg,
418 base_invnum => $cust_bill->invnum, # may be null
419 amount => $item_fee[$i],
420 base_cust_bill_pkg => $items[$i], # late resolve
424 } else { # if !@item_fee
425 # then this isn't a proportional fee, so it just applies to the
427 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
428 cust_bill_pkg => $cust_bill_pkg,
429 base_invnum => $cust_bill->invnum, # may be null
434 # cust_bill_pkg::insert will handle this
435 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
436 # avoid misbehavior by usage() and some other things
437 $cust_bill_pkg->set('details', []);
439 return $cust_bill_pkg;
442 =item itemdesc_locale LOCALE
444 Returns a customer-viewable description of this fee for the given locale,
445 from the part_fee_msgcat table. If the locale is empty or no localized fee
446 description exists, returns part_fee.itemdesc.
450 sub itemdesc_locale {
451 my ( $self, $locale ) = @_;
452 return $self->itemdesc unless $locale;
453 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
454 feepart => $self->feepart,
456 }) or return $self->itemdesc;
457 $part_fee_msgcat->itemdesc;
460 =item tax_rates DATA_PROVIDER, GEOCODE
462 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
463 fee, in the location specified by GEOCODE.
469 my ($vendor, $geocode) = @_;
470 return unless $self->taxproductnum;
471 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
473 my @taxclassnums = map { $_->taxclassnum }
474 $taxproduct->part_pkg_taxrate($geocode);
475 return unless @taxclassnums;
477 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
479 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
480 my @taxes = qsearch({ 'table' => 'tax_rate',
481 'hashref' => { 'geocode' => $geocode,
482 'data_vendor' => $vendor },
483 'extra_sql' => $extra_sql,
485 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
491 sub part_pkg_taxoverride {} # we don't do overrides here
495 return ($self->taxproductnum ? 1 : 0);
498 # stubs that will go away under 4.x
503 ? FS::pkg_class->by_key($self->classnum)
507 sub part_pkg_taxproduct {
510 ? FS::part_pkg_taxproduct->by_key($self->taxproductnum)
517 ? FS::agent->by_key($self->agentnum)
521 sub part_fee_msgcat {
523 qsearch( 'part_fee_msgcat', { feepart => $self->feepart } );
528 qsearch( 'part_fee_usage', { feepart => $self->feepart } );