4 use base qw( FS::o2m_Common FS::Record );
5 use FS::Record qw( qsearch qsearchs );
6 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
53 and placement on multisection invoices. Unlike packages, fees I<must> be
54 assigned to a class; they will default to class named "Fees", which belongs
55 to the same invoice section that normally contains taxes.
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;
136 $default_class ||= qsearchs('pkg_class', { classname => 'Fees' })
137 or die "default package fee class not found; run freeside-upgrade to continue.\n";
139 if (!$self->get('classnum')) {
140 $self->set('classnum', $default_class->classnum);
144 $self->ut_numbern('feepart')
145 || $self->ut_textn('comment')
146 || $self->ut_flag('disabled')
147 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
148 || $self->ut_flag('taxable')
149 || $self->ut_textn('taxclass')
150 || $self->ut_numbern('taxproductnum')
151 || $self->ut_floatn('pay_weight')
152 || $self->ut_floatn('credit_weight')
153 || $self->ut_agentnum_acl('agentnum',
154 [ 'Edit global package definitions' ])
155 || $self->ut_money('amount')
156 || $self->ut_float('percent')
157 || $self->ut_moneyn('minimum')
158 || $self->ut_moneyn('maximum')
159 || $self->ut_flag('limit_credit')
160 || $self->ut_enum('basis', [ 'charged', 'owed', 'usage' ])
161 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
163 return $error if $error;
165 if ( $self->get('limit_credit') ) {
166 $self->set('maximum', '');
169 if ( $self->get('basis') eq 'usage' ) {
170 # to avoid confusion, don't also allow charging a percentage
171 $self->set('percent', 0);
179 Returns a string describing how this fee is calculated.
185 # XXX customer currency
186 my $money_char = FS::Conf->new->config('money_char') || '$';
187 my $money = $money_char . '%.2f';
188 my $percent = '%.1f%%';
190 if ( $self->amount > 0 ) {
191 $string = sprintf($money, $self->amount);
193 if ( $self->percent > 0 ) {
197 $string .= sprintf($percent, $self->percent);
198 $string .= ' of the ';
199 if ( $self->basis eq 'charged' ) {
200 $string .= 'invoice amount';
201 } elsif ( $self->basis('owed') ) {
202 $string .= 'unpaid invoice balance';
204 } elsif ( $self->basis eq 'usage' ) {
206 $string .= " plus \n";
208 # append per-class descriptions
209 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
212 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
214 if ( $self->minimum ) {
215 $string .= ' at least '.sprintf($money, $self->minimum);
217 if ( $self->maximum ) {
218 $string .= ' and' if $self->minimum;
219 $string .= ' at most '.sprintf($money, $self->maximum);
221 if ( $self->limit_credit ) {
222 if ( $self->maximum ) {
223 $string .= ", or the customer's credit balance, whichever is less.";
225 $string .= ' and' if $self->minimum;
226 $string .= " not more than the customer's credit balance";
233 =item lineitem INVOICE
235 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
236 representing the invoice line item for the fee, with linked
237 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
238 its line items, as appropriate.
240 If the fee is going to be charged on the upcoming invoice (credit card
241 processing fees, postal invoice fees), INVOICE should be an uninserted
242 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
243 of the non-fee line items that will appear on the invoice.
249 my $cust_bill = shift;
250 my $cust_main = $cust_bill->cust_main;
252 my $amount = 0 + $self->get('amount');
253 my $total_base; # sum of base line items
254 my @items; # base line items (cust_bill_pkg records)
255 my @item_base; # charged/owed of that item (sequential w/ @items)
256 my @item_fee; # fee amount of that item (sequential w/ @items)
257 my @cust_bill_pkg_fee; # link record
259 warn "Calculating fee: ".$self->itemdesc." on ".
260 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
262 my $basis = $self->basis;
264 # $total_base: the total charged/owed on the invoice
265 # %item_base: billpkgnum => fraction of base amount
266 if ( $cust_bill->invnum ) {
268 # calculate the fee on an already-inserted past invoice. This may have
269 # payments or credits, so if basis = owed, we need to consider those.
270 @items = $cust_bill->cust_bill_pkg;
271 if ( $basis ne 'usage' ) {
273 $total_base = $cust_bill->$basis; # "charged", "owed"
274 my $basis_sql = $basis.'_sql';
275 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
276 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
277 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
280 $amount += $total_base * $self->percent / 100;
283 # the fee applies to _this_ invoice. It has no payments or credits, so
284 # "charged" and "owed" basis are both just the invoice amount, and
285 # the line item amounts (setup + recur)
286 @items = @{ $cust_bill->get('cust_bill_pkg') };
287 if ( $basis ne 'usage' ) {
288 $total_base = $cust_bill->charged;
289 @item_base = map { $_->setup + $_->recur }
292 $amount += $total_base * $self->percent / 100;
296 if ( $basis eq 'usage' ) {
298 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
300 foreach my $item (@items) { # cust_bill_pkg objects
302 $item->regularize_details;
304 if ( $item->billpkgnum ) {
306 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
309 $details = $item->get('details') || [];
311 foreach my $d (@$details) {
312 # if there's a usage fee defined for this class...
313 next if $d->amount eq '' # not a real usage detail
314 or $d->amount == 0 # zero charge, probably shouldn't charge fee
316 my $p = $part_fee_usage{$d->classnum} or next;
317 $usage_fee += ($d->amount * $p->percent / 100)
319 # we'd create detail records here if we were doing that
321 # bypass @item_base entirely
322 push @item_fee, $usage_fee;
323 $amount += $usage_fee;
326 } # if $basis eq 'usage'
328 if ( $self->minimum ne '' and $amount < $self->minimum ) {
329 warn "Applying mininum fee\n" if $DEBUG;
330 $amount = $self->minimum;
333 my $maximum = $self->maximum;
334 if ( $self->limit_credit ) {
335 my $balance = $cust_bill->cust_main->balance;
336 if ( $balance >= 0 ) {
337 warn "Credit balance is zero, so fee is zero" if $DEBUG;
338 return; # don't bother doing estimated tax, etc.
339 } elsif ( -1 * $balance < $maximum ) {
340 $maximum = -1 * $balance;
343 if ( $maximum ne '' and $amount > $maximum ) {
344 warn "Applying maximum fee\n" if $DEBUG;
348 # at this point, if the fee is zero, return nothing
349 return if $amount < 0.005;
350 $amount = sprintf('%.2f', $amount);
352 my $cust_bill_pkg = FS::cust_bill_pkg->new({
353 feepart => $self->feepart,
355 # no sdate/edate, right?
360 if ( $maximum and $self->taxable ) {
361 warn "Estimating taxes on fee.\n" if $DEBUG;
362 # then we need to estimate tax to respect the maximum
363 # XXX currently doesn't work with external (tax_rate) taxes
364 # or batch taxes, obviously
365 my $taxlisthash = {};
366 my $error = $cust_main->_handle_taxes(
369 location => $cust_main->ship_location
372 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
373 my @taxes = map { $_->[0] } values %$taxlisthash;
375 $total_rate += $_->tax;
377 if ($total_rate > 0) {
378 my $max_cents = $maximum * 100;
379 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
380 # the actual maximum that we can charge...
381 $maximum = sprintf('%.2f', $charge_cents / 100.00);
382 $amount = $maximum if $amount > $maximum;
384 } # if $maximum and $self->taxable
386 # set the amount that we'll charge
387 $cust_bill_pkg->set( $self->setuprecur, $amount );
389 # create display record
390 my $categoryname = '';
391 if ( $self->classnum ) {
392 my $pkg_category = $self->pkg_class->pkg_category;
393 $categoryname = $pkg_category->categoryname if $pkg_category;
395 my $displaytype = ($self->setuprecur eq 'setup') ? 'S' : 'R';
396 my $display = FS::cust_bill_pkg_display->new({
397 type => $displaytype,
398 section => $categoryname,
399 # post_total? summary? who the hell knows?
401 $cust_bill_pkg->set('display', [ $display ]);
403 # if this is a percentage fee and has line item fractions,
404 # adjust them to be proportional and to add up correctly.
406 my $cents = $amount * 100;
407 # not necessarily the same as percent
408 my $multiplier = $amount / $total_base;
409 for (my $i = 0; $i < scalar(@items); $i++) {
410 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
411 $item_fee[$i] = $fee;
412 $cents -= $fee * 100;
414 # correct rounding error
415 while ($cents >= 0.5 or $cents < -0.5) {
416 foreach my $fee (@item_fee) {
417 if ( $cents >= 0.5 ) {
420 } elsif ( $cents < -0.5 ) {
428 # add allocation records to the cust_bill_pkg
429 for (my $i = 0; $i < scalar(@items); $i++) {
430 if ( $item_fee[$i] > 0 ) {
431 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
432 cust_bill_pkg => $cust_bill_pkg,
433 base_invnum => $cust_bill->invnum, # may be null
434 amount => $item_fee[$i],
435 base_cust_bill_pkg => $items[$i], # late resolve
439 } else { # if !@item_fee
440 # then this isn't a proportional fee, so it just applies to the
442 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
443 cust_bill_pkg => $cust_bill_pkg,
444 base_invnum => $cust_bill->invnum, # may be null
449 # cust_bill_pkg::insert will handle this
450 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
451 # avoid misbehavior by usage() and some other things
452 $cust_bill_pkg->set('details', []);
454 return $cust_bill_pkg;
457 =item itemdesc_locale LOCALE
459 Returns a customer-viewable description of this fee for the given locale,
460 from the part_fee_msgcat table. If the locale is empty or no localized fee
461 description exists, returns part_fee.itemdesc.
465 sub itemdesc_locale {
466 my ( $self, $locale ) = @_;
467 return $self->itemdesc unless $locale;
468 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
469 feepart => $self->feepart,
471 }) or return $self->itemdesc;
472 $part_fee_msgcat->itemdesc;
475 =item tax_rates DATA_PROVIDER, GEOCODE
477 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
478 fee, in the location specified by GEOCODE.
484 my ($vendor, $geocode) = @_;
485 return unless $self->taxproductnum;
486 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
488 my @taxclassnums = map { $_->taxclassnum }
489 $taxproduct->part_pkg_taxrate($geocode);
490 return unless @taxclassnums;
492 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
494 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
495 my @taxes = qsearch({ 'table' => 'tax_rate',
496 'hashref' => { 'geocode' => $geocode,
497 'data_vendor' => $vendor },
498 'extra_sql' => $extra_sql,
500 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
508 Returns the package category name, or the empty string if there is no package
515 my $pkg_class = $self->pkg_class;
516 $pkg_class ? $pkg_class->categoryname : '';
519 sub part_pkg_taxoverride {} # we don't do overrides here
523 return ($self->taxproductnum ? 1 : 0);