4 use base qw( FS::o2m_Common FS::Record );
6 use FS::Record qw( qsearch qsearchs );
8 use FS::part_pkg_taxproduct;
15 FS::part_fee - Object methods for part_fee records
21 $record = new FS::part_fee \%hash;
22 $record = new FS::part_fee { 'column' => 'value' };
24 $error = $record->insert;
26 $error = $new_record->replace($old_record);
28 $error = $record->delete;
30 $error = $record->check;
34 An FS::part_fee object represents the definition of a fee
36 Fees are like packages, but instead of being ordered and then billed on a
37 cycle, they are created by the operation of events and added to a single
38 invoice. The fee definition specifies the fee's description, how the amount
39 is calculated (a flat fee or a percentage of the customer's balance), and
40 how to classify the fee for tax and reporting purposes.
42 FS::part_fee inherits from FS::Record. The following fields are currently
47 =item feepart - primary key
49 =item comment - a description of the fee for employee use, not shown on
52 =item disabled - 'Y' if the fee is disabled
54 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
56 =item taxable - 'Y' if this fee should be considered a taxable sale.
57 Currently, taxable fees will be treated like they exist at the customer's
58 default service location.
60 =item taxclass - the tax class the fee belongs to, as a string, for the
63 =item taxproductnum - the tax product family the fee belongs to, for the
64 external tax system in use, if any
66 =item pay_weight - Weight (relative to credit_weight and other package/fee
67 definitions) that controls payment application to specific line items.
69 =item credit_weight - Weight that controls credit application to specific
72 =item agentnum - the agent (L<FS::agent>) who uses this fee definition.
74 =item amount - the flat fee to charge, as a decimal amount
76 =item percent - the percentage of the base to charge (out of 100). If both
77 this and "amount" are specified, the fee will be the sum of the two.
79 =item basis - the method for calculating the base: currently one of "charged",
82 =item minimum - the minimum fee that should be charged
84 =item maximum - the maximum fee that should be charged
86 =item limit_credit - 'Y' to set the maximum fee at the customer's credit
89 =item setuprecur - whether the fee should be classified as 'setup' or
90 'recur', for reporting purposes.
100 Creates a new fee definition. To add the record to the database, see
105 sub table { 'part_fee'; }
109 Adds this record to the database. If there is an error, returns the error,
110 otherwise returns false.
114 Delete this record from the database.
116 =item replace OLD_RECORD
118 Replaces the OLD_RECORD with this one in the database. If there is an error,
119 returns the error, otherwise returns false.
123 Checks all fields to make sure this is a valid example. If there is
124 an error, returns the error, otherwise returns false. Called by the insert
132 $self->set('amount', 0) unless $self->amount;
133 $self->set('percent', 0) unless $self->percent;
136 $self->ut_numbern('feepart')
137 || $self->ut_textn('comment')
138 || $self->ut_flag('disabled')
139 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
140 || $self->ut_flag('taxable')
141 || $self->ut_textn('taxclass')
142 || $self->ut_numbern('taxproductnum')
143 || $self->ut_floatn('pay_weight')
144 || $self->ut_floatn('credit_weight')
145 || $self->ut_agentnum_acl('agentnum',
146 [ 'Edit global package definitions' ])
147 || $self->ut_money('amount')
148 || $self->ut_float('percent')
149 || $self->ut_moneyn('minimum')
150 || $self->ut_moneyn('maximum')
151 || $self->ut_flag('limit_credit')
152 || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
153 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
155 return $error if $error;
157 return "For a percentage fee, the basis must be set"
158 if $self->get('percent') > 0 and $self->get('basis') eq '';
160 if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
161 # then it makes no sense to apply minimum/maximum
162 $self->set('minimum', '');
163 $self->set('maximum', '');
165 if ( $self->get('limit_credit') ) {
166 $self->set('maximum', '');
174 Returns a string describing how this fee is calculated.
180 # XXX customer currency
181 my $money_char = FS::Conf->new->config('money_char') || '$';
182 my $money = $money_char . '%.2f';
183 my $percent = '%.1f%%';
185 if ( $self->amount > 0 ) {
186 $string = sprintf($money, $self->amount);
188 if ( $self->percent > 0 ) {
192 $string .= sprintf($percent, $self->percent);
193 $string .= ' of the ';
194 if ( $self->basis eq 'charged' ) {
195 $string .= 'invoice amount';
196 } elsif ( $self->basis('owed') ) {
197 $string .= 'unpaid invoice balance';
200 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
202 if ( $self->minimum ) {
203 $string .= ' at least '.sprintf($money, $self->minimum);
205 if ( $self->maximum ) {
206 $string .= ' and' if $self->minimum;
207 $string .= ' at most '.sprintf($money, $self->maximum);
209 if ( $self->limit_credit ) {
210 if ( $self->maximum ) {
211 $string .= ", or the customer's credit balance, whichever is less.";
213 $string .= ' and' if $self->minimum;
214 $string .= " not more than the customer's credit balance";
221 =item lineitem INVOICE
223 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
224 representing the invoice line item for the fee, with linked
225 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
226 its line items, as appropriate.
228 If the fee is going to be charged on the upcoming invoice (credit card
229 processing fees, postal invoice fees), INVOICE should be an uninserted
230 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
231 of the non-fee line items that will appear on the invoice.
237 my $cust_bill = shift;
238 my $cust_main = $cust_bill->cust_main;
240 my $amount = 0 + $self->get('amount');
241 my $total_base; # sum of base line items
242 my @items; # base line items (cust_bill_pkg records)
243 my @item_base; # charged/owed of that item (sequential w/ @items)
244 my @item_fee; # fee amount of that item (sequential w/ @items)
245 my @cust_bill_pkg_fee; # link record
247 warn "Calculating fee: ".$self->itemdesc." on ".
248 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
250 if ( $self->percent > 0 and $self->basis ne '' ) {
251 warn $self->percent . "% of amount ".$self->basis.")\n"
254 # $total_base: the total charged/owed on the invoice
255 # %item_base: billpkgnum => fraction of base amount
256 if ( $cust_bill->invnum ) {
257 my $basis = $self->basis;
258 $total_base = $cust_bill->$basis; # "charged", "owed"
260 # calculate the fee on an already-inserted past invoice. This may have
261 # payments or credits, so if basis = owed, we need to consider those.
262 my $basis_sql = $basis.'_sql';
263 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
264 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
265 @items = $cust_bill->cust_bill_pkg;
266 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
269 # the fee applies to _this_ invoice. It has no payments or credits, so
270 # "charged" and "owed" basis are both just the invoice amount, and
271 # the line item amounts (setup + recur)
272 $total_base = $cust_bill->charged;
273 @items = @{ $cust_bill->get('cust_bill_pkg') };
274 @item_base = map { $_->setup + $_->recur }
278 $amount += $total_base * $self->percent / 100;
281 if ( $self->minimum ne '' and $amount < $self->minimum ) {
282 warn "Applying mininum fee\n" if $DEBUG;
283 $amount = $self->minimum;
286 my $maximum = $self->maximum;
287 if ( $self->limit_credit ) {
288 my $balance = $cust_bill->cust_main->balance;
289 if ( $balance >= 0 ) {
290 warn "Credit balance is zero, so fee is zero" if $DEBUG;
291 return; # don't bother doing estimated tax, etc.
292 } elsif ( -1 * $balance < $maximum ) {
293 $maximum = -1 * $balance;
296 if ( $maximum ne '' and $amount > $maximum ) {
297 warn "Applying maximum fee\n" if $DEBUG;
301 # at this point, if the fee is zero, return nothing
302 return if $amount < 0.005;
303 $amount = sprintf('%.2f', $amount);
305 my $cust_bill_pkg = FS::cust_bill_pkg->new({
306 feepart => $self->feepart,
308 # no sdate/edate, right?
313 if ( $maximum and $self->taxable ) {
314 warn "Estimating taxes on fee.\n" if $DEBUG;
315 # then we need to estimate tax to respect the maximum
316 # XXX currently doesn't work with external (tax_rate) taxes
317 # or batch taxes, obviously
318 my $taxlisthash = {};
319 my $error = $cust_main->_handle_taxes(
322 location => $cust_main->ship_location
325 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
326 my @taxes = map { $_->[0] } values %$taxlisthash;
328 $total_rate += $_->tax;
330 if ($total_rate > 0) {
331 my $max_cents = $maximum * 100;
332 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
333 # the actual maximum that we can charge...
334 $maximum = sprintf('%.2f', $charge_cents / 100.00);
335 $amount = $maximum if $amount > $maximum;
337 } # if $maximum and $self->taxable
339 # set the amount that we'll charge
340 $cust_bill_pkg->set( $self->setuprecur, $amount );
342 if ( $self->classnum ) {
343 my $pkg_category = $self->pkg_class->pkg_category;
344 $cust_bill_pkg->set('section' => $pkg_category->categoryname)
348 # if this is a percentage fee and has line item fractions,
349 # adjust them to be proportional and to add up correctly.
351 my $cents = $amount * 100;
352 # not necessarily the same as percent
353 my $multiplier = $amount / $total_base;
354 for (my $i = 0; $i < scalar(@items); $i++) {
355 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
356 $item_fee[$i] = $fee;
357 $cents -= $fee * 100;
359 # correct rounding error
360 while ($cents >= 0.5 or $cents < -0.5) {
361 foreach my $fee (@item_fee) {
362 if ( $cents >= 0.5 ) {
365 } elsif ( $cents < -0.5 ) {
371 # and add them to the cust_bill_pkg
372 for (my $i = 0; $i < scalar(@items); $i++) {
373 if ( $item_fee[$i] > 0 ) {
374 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
375 cust_bill_pkg => $cust_bill_pkg,
376 base_invnum => $cust_bill->invnum,
377 amount => $item_fee[$i],
378 base_cust_bill_pkg => $items[$i], # late resolve
382 } else { # if !@item_base
383 # then this isn't a proportional fee, so it just applies to the
385 # (if it's the current invoice, $cust_bill->invnum is null and that
386 # will be fixed later)
387 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
388 cust_bill_pkg => $cust_bill_pkg,
389 base_invnum => $cust_bill->invnum,
394 # cust_bill_pkg::insert will handle this
395 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
396 # avoid misbehavior by usage() and some other things
397 $cust_bill_pkg->set('details', []);
399 return $cust_bill_pkg;
402 =item itemdesc_locale LOCALE
404 Returns a customer-viewable description of this fee for the given locale,
405 from the part_fee_msgcat table. If the locale is empty or no localized fee
406 description exists, returns part_fee.itemdesc.
410 sub itemdesc_locale {
411 my ( $self, $locale ) = @_;
412 return $self->itemdesc unless $locale;
413 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
414 feepart => $self->feepart,
416 }) or return $self->itemdesc;
417 $part_fee_msgcat->itemdesc;
420 =item tax_rates DATA_PROVIDER, GEOCODE
422 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
423 fee, in the location specified by GEOCODE.
429 my ($vendor, $geocode) = @_;
430 return unless $self->taxproductnum;
431 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
433 my @taxclassnums = map { $_->taxclassnum }
434 $taxproduct->part_pkg_taxrate($geocode);
435 return unless @taxclassnums;
437 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
439 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
440 my @taxes = qsearch({ 'table' => 'tax_rate',
441 'hashref' => { 'geocode' => $geocode,
442 'data_vendor' => $vendor },
443 'extra_sql' => $extra_sql,
445 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
451 sub part_pkg_taxoverride {} # we don't do overrides here
455 return ($self->taxproductnum ? 1 : 0);
458 # stubs that will go away under 4.x
463 ? FS::pkg_class->by_key($self->classnum)
467 sub part_pkg_taxproduct {
470 ? FS::part_pkg_taxproduct->by_key($self->taxproductnum)
477 ? FS::agent->by_key($self->agentnum)
481 sub part_fee_msgcat {
483 qsearch( 'part_fee_msgcat', { feepart => $self->feepart } );