4 use base qw( FS::o2m_Common FS::Record );
6 use FS::Record qw( qsearch qsearchs );
12 FS::part_fee - Object methods for part_fee records
18 $record = new FS::part_fee \%hash;
19 $record = new FS::part_fee { 'column' => 'value' };
21 $error = $record->insert;
23 $error = $new_record->replace($old_record);
25 $error = $record->delete;
27 $error = $record->check;
31 An FS::part_fee object represents the definition of a fee
33 Fees are like packages, but instead of being ordered and then billed on a
34 cycle, they are created by the operation of events and added to a single
35 invoice. The fee definition specifies the fee's description, how the amount
36 is calculated (a flat fee or a percentage of the customer's balance), and
37 how to classify the fee for tax and reporting purposes.
39 FS::part_fee inherits from FS::Record. The following fields are currently
44 =item feepart - primary key
46 =item comment - a description of the fee for employee use, not shown on
49 =item disabled - 'Y' if the fee is disabled
51 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
53 =item taxable - 'Y' if this fee should be considered a taxable sale.
54 Currently, taxable fees will be treated like they exist at the customer's
55 default service location.
57 =item taxclass - the tax class the fee belongs to, as a string, for the
60 =item taxproductnum - the tax product family the fee belongs to, for the
61 external tax system in use, if any
63 =item pay_weight - Weight (relative to credit_weight and other package/fee
64 definitions) that controls payment application to specific line items.
66 =item credit_weight - Weight that controls credit application to specific
69 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
71 =item amount - the flat fee to charge, as a decimal amount
73 =item percent - the percentage of the base to charge (out of 100). If both
74 this and "amount" are specified, the fee will be the sum of the two.
76 =item basis - the method for calculating the base: currently one of "charged",
79 =item minimum - the minimum fee that should be charged
81 =item maximum - the maximum fee that should be charged
83 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
86 =item setuprecur - whether the fee should be classified as 'setup' or
87 'recur', for reporting purposes.
97 Creates a new fee definition. To add the record to the database, see
102 sub table { 'part_fee'; }
106 Adds this record to the database. If there is an error, returns the error,
107 otherwise returns false.
111 Delete this record from the database.
113 =item replace OLD_RECORD
115 Replaces the OLD_RECORD with this one in the database. If there is an error,
116 returns the error, otherwise returns false.
120 Checks all fields to make sure this is a valid example. If there is
121 an error, returns the error, otherwise returns false. Called by the insert
129 $self->set('amount', 0) unless $self->amount;
130 $self->set('percent', 0) unless $self->percent;
133 $self->ut_numbern('feepart')
134 || $self->ut_textn('comment')
135 || $self->ut_flag('disabled')
136 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
137 || $self->ut_flag('taxable')
138 || $self->ut_textn('taxclass')
139 || $self->ut_numbern('taxproductnum')
140 || $self->ut_floatn('pay_weight')
141 || $self->ut_floatn('credit_weight')
142 || $self->ut_agentnum_acl('agentnum',
143 [ 'Edit global package definitions' ])
144 || $self->ut_money('amount')
145 || $self->ut_float('percent')
146 || $self->ut_moneyn('minimum')
147 || $self->ut_moneyn('maximum')
148 || $self->ut_flag('limit_credit')
149 || $self->ut_enum('basis', [ 'charged', 'owed', 'usage' ])
150 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
152 return $error if $error;
154 if ( $self->get('limit_credit') ) {
155 $self->set('maximum', '');
158 if ( $self->get('basis') eq 'usage' ) {
159 # to avoid confusion, don't also allow charging a percentage
160 $self->set('percent', 0);
168 Returns a string describing how this fee is calculated.
174 # XXX customer currency
175 my $money_char = FS::Conf->new->config('money_char') || '$';
176 my $money = $money_char . '%.2f';
177 my $percent = '%.1f%%';
179 if ( $self->amount > 0 ) {
180 $string = sprintf($money, $self->amount);
182 if ( $self->percent > 0 ) {
186 $string .= sprintf($percent, $self->percent);
187 $string .= ' of the ';
188 if ( $self->basis eq 'charged' ) {
189 $string .= 'invoice amount';
190 } elsif ( $self->basis('owed') ) {
191 $string .= 'unpaid invoice balance';
193 } elsif ( $self->basis eq 'usage' ) {
195 $string .= " plus \n";
197 # append per-class descriptions
198 $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
201 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
203 if ( $self->minimum ) {
204 $string .= ' at least '.sprintf($money, $self->minimum);
206 if ( $self->maximum ) {
207 $string .= ' and' if $self->minimum;
208 $string .= ' at most '.sprintf($money, $self->maximum);
210 if ( $self->limit_credit ) {
211 if ( $self->maximum ) {
212 $string .= ", or the customer's credit balance, whichever is less.";
214 $string .= ' and' if $self->minimum;
215 $string .= " not more than the customer's credit balance";
222 =item lineitem INVOICE
224 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
225 representing the invoice line item for the fee, with linked
226 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
227 its line items, as appropriate.
229 If the fee is going to be charged on the upcoming invoice (credit card
230 processing fees, postal invoice fees), INVOICE should be an uninserted
231 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
232 of the non-fee line items that will appear on the invoice.
238 my $cust_bill = shift;
239 my $cust_main = $cust_bill->cust_main;
241 my $amount = 0 + $self->get('amount');
242 my $total_base; # sum of base line items
243 my @items; # base line items (cust_bill_pkg records)
244 my @item_base; # charged/owed of that item (sequential w/ @items)
245 my @item_fee; # fee amount of that item (sequential w/ @items)
246 my @cust_bill_pkg_fee; # link record
248 warn "Calculating fee: ".$self->itemdesc." on ".
249 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
251 my $basis = $self->basis;
253 # $total_base: the total charged/owed on the invoice
254 # %item_base: billpkgnum => fraction of base amount
255 if ( $cust_bill->invnum ) {
257 # calculate the fee on an already-inserted past invoice. This may have
258 # payments or credits, so if basis = owed, we need to consider those.
259 @items = $cust_bill->cust_bill_pkg;
260 if ( $basis ne 'usage' ) {
262 $total_base = $cust_bill->$basis; # "charged", "owed"
263 my $basis_sql = $basis.'_sql';
264 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
265 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
266 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
269 $amount += $total_base * $self->percent / 100;
272 # the fee applies to _this_ invoice. It has no payments or credits, so
273 # "charged" and "owed" basis are both just the invoice amount, and
274 # the line item amounts (setup + recur)
275 @items = @{ $cust_bill->get('cust_bill_pkg') };
276 if ( $basis ne 'usage' ) {
277 $total_base = $cust_bill->charged;
278 @item_base = map { $_->setup + $_->recur }
281 $amount += $total_base * $self->percent / 100;
285 if ( $basis eq 'usage' ) {
287 my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
289 foreach my $item (@items) { # cust_bill_pkg objects
291 $item->regularize_details;
293 if ( $item->billpkgnum ) {
295 qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
298 $details = $item->get('details') || [];
300 foreach my $d (@$details) {
301 # if there's a usage fee defined for this class...
302 next if $d->amount eq '' # not a real usage detail
303 or $d->amount == 0 # zero charge, probably shouldn't charge fee
305 my $p = $part_fee_usage{$d->classnum} or next;
306 $usage_fee += ($d->amount * $p->percent / 100)
308 # we'd create detail records here if we were doing that
310 # bypass @item_base entirely
311 push @item_fee, $usage_fee;
312 $amount += $usage_fee;
315 } # if $basis eq 'usage'
317 if ( $self->minimum ne '' and $amount < $self->minimum ) {
318 warn "Applying mininum fee\n" if $DEBUG;
319 $amount = $self->minimum;
322 my $maximum = $self->maximum;
323 if ( $self->limit_credit ) {
324 my $balance = $cust_bill->cust_main->balance;
325 if ( $balance >= 0 ) {
326 warn "Credit balance is zero, so fee is zero" if $DEBUG;
327 return; # don't bother doing estimated tax, etc.
328 } elsif ( -1 * $balance < $maximum ) {
329 $maximum = -1 * $balance;
332 if ( $maximum ne '' and $amount > $maximum ) {
333 warn "Applying maximum fee\n" if $DEBUG;
337 # at this point, if the fee is zero, return nothing
338 return if $amount < 0.005;
339 $amount = sprintf('%.2f', $amount);
341 my $cust_bill_pkg = FS::cust_bill_pkg->new({
342 feepart => $self->feepart,
344 # no sdate/edate, right?
349 if ( $maximum and $self->taxable ) {
350 warn "Estimating taxes on fee.\n" if $DEBUG;
351 # then we need to estimate tax to respect the maximum
352 # XXX currently doesn't work with external (tax_rate) taxes
353 # or batch taxes, obviously
354 my $taxlisthash = {};
355 my $error = $cust_main->_handle_taxes(
358 location => $cust_main->ship_location
361 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
362 my @taxes = map { $_->[0] } values %$taxlisthash;
364 $total_rate += $_->tax;
366 if ($total_rate > 0) {
367 my $max_cents = $maximum * 100;
368 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
369 # the actual maximum that we can charge...
370 $maximum = sprintf('%.2f', $charge_cents / 100.00);
371 $amount = $maximum if $amount > $maximum;
373 } # if $maximum and $self->taxable
375 # set the amount that we'll charge
376 $cust_bill_pkg->set( $self->setuprecur, $amount );
378 if ( $self->classnum ) {
379 my $pkg_category = $self->pkg_class->pkg_category;
380 $cust_bill_pkg->set('section' => $pkg_category->categoryname)
384 # if this is a percentage fee and has line item fractions,
385 # adjust them to be proportional and to add up correctly.
387 my $cents = $amount * 100;
388 # not necessarily the same as percent
389 my $multiplier = $amount / $total_base;
390 for (my $i = 0; $i < scalar(@items); $i++) {
391 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
392 $item_fee[$i] = $fee;
393 $cents -= $fee * 100;
395 # correct rounding error
396 while ($cents >= 0.5 or $cents < -0.5) {
397 foreach my $fee (@item_fee) {
398 if ( $cents >= 0.5 ) {
401 } elsif ( $cents < -0.5 ) {
409 # add allocation records to the cust_bill_pkg
410 for (my $i = 0; $i < scalar(@items); $i++) {
411 if ( $item_fee[$i] > 0 ) {
412 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
413 cust_bill_pkg => $cust_bill_pkg,
414 base_invnum => $cust_bill->invnum, # may be null
415 amount => $item_fee[$i],
416 base_cust_bill_pkg => $items[$i], # late resolve
420 } else { # if !@item_fee
421 # then this isn't a proportional fee, so it just applies to the
423 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
424 cust_bill_pkg => $cust_bill_pkg,
425 base_invnum => $cust_bill->invnum, # may be null
430 # cust_bill_pkg::insert will handle this
431 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
432 # avoid misbehavior by usage() and some other things
433 $cust_bill_pkg->set('details', []);
435 return $cust_bill_pkg;
438 =item itemdesc_locale LOCALE
440 Returns a customer-viewable description of this fee for the given locale,
441 from the part_fee_msgcat table. If the locale is empty or no localized fee
442 description exists, returns part_fee.itemdesc.
446 sub itemdesc_locale {
447 my ( $self, $locale ) = @_;
448 return $self->itemdesc unless $locale;
449 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
450 feepart => $self->feepart,
452 }) or return $self->itemdesc;
453 $part_fee_msgcat->itemdesc;
456 =item tax_rates DATA_PROVIDER, GEOCODE
458 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
459 fee, in the location specified by GEOCODE.
465 my ($vendor, $geocode) = @_;
466 return unless $self->taxproductnum;
467 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
469 my @taxclassnums = map { $_->taxclassnum }
470 $taxproduct->part_pkg_taxrate($geocode);
471 return unless @taxclassnums;
473 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
475 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
476 my @taxes = qsearch({ 'table' => 'tax_rate',
477 'hashref' => { 'geocode' => $geocode,
478 'data_vendor' => $vendor },
479 'extra_sql' => $extra_sql,
481 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
487 sub part_pkg_taxoverride {} # we don't do overrides here
491 return ($self->taxproductnum ? 1 : 0);