4 use base qw( FS::o2m_Common FS::Record );
5 use FS::Record qw( qsearch qsearchs );
7 use FS::cust_bill_pkg_display;
8 use FS::part_pkg_taxproduct;
10 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
57 and placement on multisection invoices. Unlike packages, fees I<must> be
58 assigned to a class; they will default to class named "Fees", which belongs
59 to the same invoice section that normally contains taxes.
61 =item taxable - 'Y' if this fee should be considered a taxable sale.
62 Currently, taxable fees will be treated like they exist at the customer's
63 default service location.
65 =item taxclass - the tax class the fee belongs to, as a string, for the
68 =item taxproductnum - the tax product family the fee belongs to, for the
69 external tax system in use, if any
71 =item pay_weight - Weight (relative to credit_weight and other package/fee
72 definitions) that controls payment application to specific line items.
74 =item credit_weight - Weight that controls credit application to specific
77 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
79 =item amount - the flat fee to charge, as a decimal amount
81 =item percent - the percentage of the base to charge (out of 100). If both
82 this and "amount" are specified, the fee will be the sum of the two.
84 =item basis - the method for calculating the base: currently one of "charged",
87 =item minimum - the minimum fee that should be charged
89 =item maximum - the maximum fee that should be charged
91 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
94 =item setuprecur - whether the fee should be classified as 'setup' or
95 'recur', for reporting purposes.
105 Creates a new fee definition. To add the record to the database, see
110 sub table { 'part_fee'; }
114 Adds this record to the database. If there is an error, returns the error,
115 otherwise returns false.
119 Delete this record from the database.
121 =item replace OLD_RECORD
123 Replaces the OLD_RECORD with this one in the database. If there is an error,
124 returns the error, otherwise returns false.
128 Checks all fields to make sure this is a valid example. If there is
129 an error, returns the error, otherwise returns false. Called by the insert
137 $self->set('amount', 0) unless $self->amount;
138 $self->set('percent', 0) unless $self->percent;
140 $default_class ||= qsearchs('pkg_class', { classname => 'Fees' })
141 or die "default package fee class not found; run freeside-upgrade to continue.\n";
143 if (!$self->get('classnum')) {
144 $self->set('classnum', $default_class->classnum);
148 $self->ut_numbern('feepart')
149 || $self->ut_textn('comment')
150 || $self->ut_flag('disabled')
151 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
152 || $self->ut_flag('taxable')
153 || $self->ut_textn('taxclass')
154 || $self->ut_numbern('taxproductnum')
155 || $self->ut_floatn('pay_weight')
156 || $self->ut_floatn('credit_weight')
157 || $self->ut_agentnum_acl('agentnum',
158 [ 'Edit global package definitions' ])
159 || $self->ut_money('amount')
160 || $self->ut_float('percent')
161 || $self->ut_moneyn('minimum')
162 || $self->ut_moneyn('maximum')
163 || $self->ut_flag('limit_credit')
164 || $self->ut_enum('basis', [ 'charged', 'owed', 'usage' ])
165 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
167 return $error if $error;
169 if ( $self->get('limit_credit') ) {
170 $self->set('maximum', '');
173 if ( $self->get('basis') eq 'usage' ) {
174 # to avoid confusion, don't also allow charging a percentage
175 $self->set('percent', 0);
183 Returns a string describing how this fee is calculated.
189 # XXX customer currency
190 my $money_char = FS::Conf->new->config('money_char') || '$';
191 my $money = $money_char . '%.2f';
192 my $percent = '%.1f%%';
194 if ( $self->amount > 0 ) {
195 $string = sprintf($money, $self->amount);
197 if ( $self->percent > 0 ) {
201 $string .= sprintf($percent, $self->percent);
202 $string .= ' of the ';
203 if ( $self->basis eq 'charged' ) {
204 $string .= 'invoice amount';
205 } elsif ( $self->basis('owed') ) {
206 $string .= 'unpaid invoice balance';
208 } elsif ( $self->basis eq 'usage' ) {
210 $string .= " plus \n";
212 # append per-class descriptions
213 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
216 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
218 if ( $self->minimum ) {
219 $string .= ' at least '.sprintf($money, $self->minimum);
221 if ( $self->maximum ) {
222 $string .= ' and' if $self->minimum;
223 $string .= ' at most '.sprintf($money, $self->maximum);
225 if ( $self->limit_credit ) {
226 if ( $self->maximum ) {
227 $string .= ", or the customer's credit balance, whichever is less.";
229 $string .= ' and' if $self->minimum;
230 $string .= " not more than the customer's credit balance";
237 =item lineitem INVOICE
239 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
240 representing the invoice line item for the fee, with linked
241 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
242 its line items, as appropriate.
244 If the fee is going to be charged on the upcoming invoice (credit card
245 processing fees, postal invoice fees), INVOICE should be an uninserted
246 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
247 of the non-fee line items that will appear on the invoice.
253 my $cust_bill = shift;
254 my $cust_main = $cust_bill->cust_main;
256 my $amount = 0 + $self->get('amount');
257 my $total_base; # sum of base line items
258 my @items; # base line items (cust_bill_pkg records)
259 my @item_base; # charged/owed of that item (sequential w/ @items)
260 my @item_fee; # fee amount of that item (sequential w/ @items)
261 my @cust_bill_pkg_fee; # link record
263 warn "Calculating fee: ".$self->itemdesc." on ".
264 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
266 my $basis = $self->basis;
268 # $total_base: the total charged/owed on the invoice
269 # %item_base: billpkgnum => fraction of base amount
270 if ( $cust_bill->invnum ) {
272 # calculate the fee on an already-inserted past invoice. This may have
273 # payments or credits, so if basis = owed, we need to consider those.
274 @items = $cust_bill->cust_bill_pkg;
275 if ( $basis ne 'usage' ) {
277 $total_base = $cust_bill->$basis; # "charged", "owed"
278 my $basis_sql = $basis.'_sql';
279 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
280 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
281 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
284 $amount += $total_base * $self->percent / 100;
287 # the fee applies to _this_ invoice. It has no payments or credits, so
288 # "charged" and "owed" basis are both just the invoice amount, and
289 # the line item amounts (setup + recur)
290 @items = @{ $cust_bill->get('cust_bill_pkg') };
291 if ( $basis ne 'usage' ) {
292 $total_base = $cust_bill->charged;
293 @item_base = map { $_->setup + $_->recur }
296 $amount += $total_base * $self->percent / 100;
300 if ( $basis eq 'usage' ) {
302 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
304 foreach my $item (@items) { # cust_bill_pkg objects
306 $item->regularize_details;
308 if ( $item->billpkgnum ) {
310 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
313 $details = $item->get('details') || [];
315 foreach my $d (@$details) {
316 # if there's a usage fee defined for this class...
317 next if $d->amount eq '' # not a real usage detail
318 or $d->amount == 0 # zero charge, probably shouldn't charge fee
320 my $p = $part_fee_usage{$d->classnum} or next;
321 $usage_fee += ($d->amount * $p->percent / 100)
323 # we'd create detail records here if we were doing that
325 # bypass @item_base entirely
326 push @item_fee, $usage_fee;
327 $amount += $usage_fee;
330 } # if $basis eq 'usage'
332 if ( $self->minimum ne '' and $amount < $self->minimum ) {
333 warn "Applying mininum fee\n" if $DEBUG;
334 $amount = $self->minimum;
337 my $maximum = $self->maximum;
338 if ( $self->limit_credit ) {
339 my $balance = $cust_bill->cust_main->balance;
340 if ( $balance >= 0 ) {
341 warn "Credit balance is zero, so fee is zero" if $DEBUG;
342 return; # don't bother doing estimated tax, etc.
343 } elsif ( -1 * $balance < $maximum ) {
344 $maximum = -1 * $balance;
347 if ( $maximum ne '' and $amount > $maximum ) {
348 warn "Applying maximum fee\n" if $DEBUG;
352 # at this point, if the fee is zero, return nothing
353 return if $amount < 0.005;
354 $amount = sprintf('%.2f', $amount);
356 my $cust_bill_pkg = FS::cust_bill_pkg->new({
357 feepart => $self->feepart,
359 # no sdate/edate, right?
364 if ( $maximum and $self->taxable ) {
365 warn "Estimating taxes on fee.\n" if $DEBUG;
366 # then we need to estimate tax to respect the maximum
367 # XXX currently doesn't work with external (tax_rate) taxes
368 # or batch taxes, obviously
369 my $taxlisthash = {};
370 my $error = $cust_main->_handle_taxes(
373 location => $cust_main->ship_location
376 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
377 my @taxes = map { $_->[0] } values %$taxlisthash;
379 $total_rate += $_->tax;
381 if ($total_rate > 0) {
382 my $max_cents = $maximum * 100;
383 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
384 # the actual maximum that we can charge...
385 $maximum = sprintf('%.2f', $charge_cents / 100.00);
386 $amount = $maximum if $amount > $maximum;
388 } # if $maximum and $self->taxable
390 # set the amount that we'll charge
391 $cust_bill_pkg->set( $self->setuprecur, $amount );
393 # create display record
394 my $categoryname = '';
395 if ( $self->classnum ) {
396 my $pkg_category = $self->pkg_class->pkg_category;
397 $categoryname = $pkg_category->categoryname if $pkg_category;
399 my $displaytype = ($self->setuprecur eq 'setup') ? 'S' : 'R';
400 my $display = FS::cust_bill_pkg_display->new({
401 type => $displaytype,
402 section => $categoryname,
403 # post_total? summary? who the hell knows?
405 $cust_bill_pkg->set('display', [ $display ]);
407 # if this is a percentage fee and has line item fractions,
408 # adjust them to be proportional and to add up correctly.
410 my $cents = $amount * 100;
411 # not necessarily the same as percent
412 my $multiplier = $amount / $total_base;
413 for (my $i = 0; $i < scalar(@items); $i++) {
414 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
415 $item_fee[$i] = $fee;
416 $cents -= $fee * 100;
418 # correct rounding error
419 while ($cents >= 0.5 or $cents < -0.5) {
420 foreach my $fee (@item_fee) {
421 if ( $cents >= 0.5 ) {
424 } elsif ( $cents < -0.5 ) {
432 # add allocation records to the cust_bill_pkg
433 for (my $i = 0; $i < scalar(@items); $i++) {
434 if ( $item_fee[$i] > 0 ) {
435 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
436 cust_bill_pkg => $cust_bill_pkg,
437 base_invnum => $cust_bill->invnum, # may be null
438 amount => $item_fee[$i],
439 base_cust_bill_pkg => $items[$i], # late resolve
443 } else { # if !@item_fee
444 # then this isn't a proportional fee, so it just applies to the
446 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
447 cust_bill_pkg => $cust_bill_pkg,
448 base_invnum => $cust_bill->invnum, # may be null
453 # cust_bill_pkg::insert will handle this
454 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
455 # avoid misbehavior by usage() and some other things
456 $cust_bill_pkg->set('details', []);
458 return $cust_bill_pkg;
461 =item itemdesc_locale LOCALE
463 Returns a customer-viewable description of this fee for the given locale,
464 from the part_fee_msgcat table. If the locale is empty or no localized fee
465 description exists, returns part_fee.itemdesc.
469 sub itemdesc_locale {
470 my ( $self, $locale ) = @_;
471 return $self->itemdesc unless $locale;
472 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
473 feepart => $self->feepart,
475 }) or return $self->itemdesc;
476 $part_fee_msgcat->itemdesc;
479 =item tax_rates DATA_PROVIDER, GEOCODE
481 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
482 fee, in the location specified by GEOCODE.
488 my ($vendor, $geocode) = @_;
489 return unless $self->taxproductnum;
490 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
492 my @taxclassnums = map { $_->taxclassnum }
493 $taxproduct->part_pkg_taxrate($geocode);
494 return unless @taxclassnums;
496 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
498 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
499 my @taxes = qsearch({ 'table' => 'tax_rate',
500 'hashref' => { 'geocode' => $geocode,
501 'data_vendor' => $vendor },
502 'extra_sql' => $extra_sql,
504 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
512 Returns the package category name, or the empty string if there is no package
519 my $pkg_class = $self->pkg_class;
520 $pkg_class ? $pkg_class->categoryname : '';
523 sub part_pkg_taxoverride {} # we don't do overrides here
527 return ($self->taxproductnum ? 1 : 0);
530 # stubs that will go away under 4.x
535 ? FS::pkg_class->by_key($self->classnum)
539 sub part_pkg_taxproduct {
542 ? FS::part_pkg_taxproduct->by_key($self->taxproductnum)
549 ? FS::agent->by_key($self->agentnum)
553 sub part_fee_msgcat {
555 qsearch( 'part_fee_msgcat', { feepart => $self->feepart } );
560 qsearch( 'part_fee_usage', { feepart => $self->feepart } );