4 use base qw( FS::o2m_Common FS::Record );
6 use FS::Record qw( qsearch qsearchs );
7 use FS::cust_bill_pkg_display;
13 FS::part_fee - Object methods for part_fee records
19 $record = new FS::part_fee \%hash;
20 $record = new FS::part_fee { 'column' => 'value' };
22 $error = $record->insert;
24 $error = $new_record->replace($old_record);
26 $error = $record->delete;
28 $error = $record->check;
32 An FS::part_fee object represents the definition of a fee
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.
40 FS::part_fee inherits from FS::Record. The following fields are currently
45 =item feepart - primary key
47 =item comment - a description of the fee for employee use, not shown on
50 =item disabled - 'Y' if the fee is disabled
52 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
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.
58 =item taxclass - the tax class the fee belongs to, as a string, for the
61 =item taxproductnum - the tax product family the fee belongs to, for the
62 external tax system in use, if any
64 =item pay_weight - Weight (relative to credit_weight and other package/fee
65 definitions) that controls payment application to specific line items.
67 =item credit_weight - Weight that controls credit application to specific
70 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
72 =item amount - the flat fee to charge, as a decimal amount
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.
77 =item basis - the method for calculating the base: currently one of "charged",
80 =item minimum - the minimum fee that should be charged
82 =item maximum - the maximum fee that should be charged
84 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
87 =item setuprecur - whether the fee should be classified as 'setup' or
88 'recur', for reporting purposes.
98 Creates a new fee definition. To add the record to the database, see
103 sub table { 'part_fee'; }
107 Adds this record to the database. If there is an error, returns the error,
108 otherwise returns false.
112 Delete this record from the database.
114 =item replace OLD_RECORD
116 Replaces the OLD_RECORD with this one in the database. If there is an error,
117 returns the error, otherwise returns false.
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
130 $self->set('amount', 0) unless $self->amount;
131 $self->set('percent', 0) unless $self->percent;
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' ])
153 return $error if $error;
155 if ( $self->get('limit_credit') ) {
156 $self->set('maximum', '');
159 if ( $self->get('basis') eq 'usage' ) {
160 # to avoid confusion, don't also allow charging a percentage
161 $self->set('percent', 0);
169 Returns a string describing how this fee is calculated.
175 # XXX customer currency
176 my $money_char = FS::Conf->new->config('money_char') || '$';
177 my $money = $money_char . '%.2f';
178 my $percent = '%.1f%%';
180 if ( $self->amount > 0 ) {
181 $string = sprintf($money, $self->amount);
183 if ( $self->percent > 0 ) {
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';
194 } elsif ( $self->basis eq 'usage' ) {
196 $string .= " plus \n";
198 # append per-class descriptions
199 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
202 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
204 if ( $self->minimum ) {
205 $string .= ' at least '.sprintf($money, $self->minimum);
207 if ( $self->maximum ) {
208 $string .= ' and' if $self->minimum;
209 $string .= ' at most '.sprintf($money, $self->maximum);
211 if ( $self->limit_credit ) {
212 if ( $self->maximum ) {
213 $string .= ", or the customer's credit balance, whichever is less.";
215 $string .= ' and' if $self->minimum;
216 $string .= " not more than the customer's credit balance";
223 =item lineitem INVOICE
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.
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.
239 my $cust_bill = shift;
240 my $cust_main = $cust_bill->cust_main;
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
249 warn "Calculating fee: ".$self->itemdesc." on ".
250 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
252 my $basis = $self->basis;
254 # $total_base: the total charged/owed on the invoice
255 # %item_base: billpkgnum => fraction of base amount
256 if ( $cust_bill->invnum ) {
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' ) {
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) }
270 $amount += $total_base * $self->percent / 100;
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 }
282 $amount += $total_base * $self->percent / 100;
286 if ( $basis eq 'usage' ) {
288 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
290 foreach my $item (@items) { # cust_bill_pkg objects
292 $item->regularize_details;
294 if ( $item->billpkgnum ) {
296 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
299 $details = $item->get('details') || [];
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
306 my $p = $part_fee_usage{$d->classnum} or next;
307 $usage_fee += ($d->amount * $p->percent / 100)
309 # we'd create detail records here if we were doing that
311 # bypass @item_base entirely
312 push @item_fee, $usage_fee;
313 $amount += $usage_fee;
316 } # if $basis eq 'usage'
318 if ( $self->minimum ne '' and $amount < $self->minimum ) {
319 warn "Applying mininum fee\n" if $DEBUG;
320 $amount = $self->minimum;
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;
333 if ( $maximum ne '' and $amount > $maximum ) {
334 warn "Applying maximum fee\n" if $DEBUG;
338 # at this point, if the fee is zero, return nothing
339 return if $amount < 0.005;
340 $amount = sprintf('%.2f', $amount);
342 my $cust_bill_pkg = FS::cust_bill_pkg->new({
343 feepart => $self->feepart,
345 # no sdate/edate, right?
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(
359 location => $cust_main->ship_location
362 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
363 my @taxes = map { $_->[0] } values %$taxlisthash;
365 $total_rate += $_->tax;
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;
374 } # if $maximum and $self->taxable
376 # set the amount that we'll charge
377 $cust_bill_pkg->set( $self->setuprecur, $amount );
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;
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?
391 $cust_bill_pkg->set('display', [ $display ]);
393 # if this is a percentage fee and has line item fractions,
394 # adjust them to be proportional and to add up correctly.
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;
404 # correct rounding error
405 while ($cents >= 0.5 or $cents < -0.5) {
406 foreach my $fee (@item_fee) {
407 if ( $cents >= 0.5 ) {
410 } elsif ( $cents < -0.5 ) {
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
429 } else { # if !@item_fee
430 # then this isn't a proportional fee, so it just applies to the
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
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', []);
444 return $cust_bill_pkg;
447 =item itemdesc_locale LOCALE
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.
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,
461 }) or return $self->itemdesc;
462 $part_fee_msgcat->itemdesc;
465 =item tax_rates DATA_PROVIDER, GEOCODE
467 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
468 fee, in the location specified by GEOCODE.
474 my ($vendor, $geocode) = @_;
475 return unless $self->taxproductnum;
476 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
478 my @taxclassnums = map { $_->taxclassnum }
479 $taxproduct->part_pkg_taxrate($geocode);
480 return unless @taxclassnums;
482 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
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,
490 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
498 Returns the package category name, or the empty string if there is no package
505 my $pkg_class = $self->pkg_class;
506 $pkg_class ? $pkg_class->categoryname : '';
509 sub part_pkg_taxoverride {} # we don't do overrides here
513 return ($self->taxproductnum ? 1 : 0);