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;
132 $self->ut_numbern('feepart')
133 || $self->ut_textn('comment')
134 || $self->ut_flag('disabled')
135 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
136 || $self->ut_flag('taxable')
137 || $self->ut_textn('taxclass')
138 || $self->ut_numbern('taxproductnum')
139 || $self->ut_floatn('pay_weight')
140 || $self->ut_floatn('credit_weight')
141 || $self->ut_agentnum_acl('agentnum',
142 [ 'Edit global package definitions' ])
143 || $self->ut_moneyn('amount')
144 || $self->ut_floatn('percent')
145 || $self->ut_moneyn('minimum')
146 || $self->ut_moneyn('maximum')
147 || $self->ut_flag('limit_credit')
148 || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
149 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
151 return $error if $error;
153 return "For a percentage fee, the basis must be set"
154 if $self->get('percent') > 0 and $self->get('basis') eq '';
156 if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
157 # then it makes no sense to apply minimum/maximum
158 $self->set('minimum', '');
159 $self->set('maximum', '');
161 if ( $self->get('limit_credit') ) {
162 $self->set('maximum', '');
170 Returns a string describing how this fee is calculated.
176 # XXX customer currency
177 my $money_char = FS::Conf->new->config('money_char') || '$';
178 my $money = $money_char . '%.2f';
179 my $percent = '%.1f%%';
181 if ( $self->amount > 0 ) {
182 $string = sprintf($money, $self->amount);
184 if ( $self->percent > 0 ) {
188 $string .= sprintf($percent, $self->percent);
189 $string .= ' of the ';
190 if ( $self->basis eq 'charged' ) {
191 $string .= 'invoice amount';
192 } elsif ( $self->basis('owed') ) {
193 $string .= 'unpaid invoice balance';
196 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
198 if ( $self->minimum ) {
199 $string .= ' at least '.sprintf($money, $self->minimum);
201 if ( $self->maximum ) {
202 $string .= ' and' if $self->minimum;
203 $string .= ' at most '.sprintf($money, $self->maximum);
205 if ( $self->limit_credit ) {
206 if ( $self->maximum ) {
207 $string .= ", or the customer's credit balance, whichever is less.";
209 $string .= ' and' if $self->minimum;
210 $string .= " not more than the customer's credit balance";
217 =item lineitem INVOICE
219 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
220 representing the invoice line item for the fee, with linked
221 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
222 its line items, as appropriate.
224 If the fee is going to be charged on the upcoming invoice (credit card
225 processing fees, postal invoice fees), INVOICE should be an uninserted
226 L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
227 of the non-fee line items that will appear on the invoice.
233 my $cust_bill = shift;
234 my $cust_main = $cust_bill->cust_main;
236 my $amount = 0 + $self->get('amount');
237 my $total_base; # sum of base line items
238 my @items; # base line items (cust_bill_pkg records)
239 my @item_base; # charged/owed of that item (sequential w/ @items)
240 my @item_fee; # fee amount of that item (sequential w/ @items)
241 my @cust_bill_pkg_fee; # link record
243 warn "Calculating fee: ".$self->itemdesc." on ".
244 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
246 if ( $self->percent > 0 and $self->basis ne '' ) {
247 warn $self->percent . "% of amount ".$self->basis.")\n"
250 # $total_base: the total charged/owed on the invoice
251 # %item_base: billpkgnum => fraction of base amount
252 if ( $cust_bill->invnum ) {
253 my $basis = $self->basis;
254 $total_base = $cust_bill->$basis; # "charged", "owed"
256 # calculate the fee on an already-inserted past invoice. This may have
257 # payments or credits, so if basis = owed, we need to consider those.
258 my $basis_sql = $basis.'_sql';
259 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
260 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
261 @items = $cust_bill->cust_bill_pkg;
262 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
265 # the fee applies to _this_ invoice. It has no payments or credits, so
266 # "charged" and "owed" basis are both just the invoice amount, and
267 # the line item amounts (setup + recur)
268 $total_base = $cust_bill->charged;
269 @items = @{ $cust_bill->get('cust_bill_pkg') };
270 @item_base = map { $_->setup + $_->recur }
274 $amount += $total_base * $self->percent / 100;
277 if ( $self->minimum ne '' and $amount < $self->minimum ) {
278 warn "Applying mininum fee\n" if $DEBUG;
279 $amount = $self->minimum;
282 my $maximum = $self->maximum;
283 if ( $self->limit_credit ) {
284 my $balance = $cust_bill->cust_main->balance;
285 if ( $balance >= 0 ) {
286 warn "Credit balance is zero, so fee is zero" if $DEBUG;
287 return; # don't bother doing estimated tax, etc.
288 } elsif ( -1 * $balance < $maximum ) {
289 $maximum = -1 * $balance;
292 if ( $maximum ne '' ) {
293 warn "Applying maximum fee\n" if $DEBUG;
297 # at this point, if the fee is zero, return nothing
298 return if $amount < 0.005;
299 $amount = sprintf('%.2f', $amount);
301 my $cust_bill_pkg = FS::cust_bill_pkg->new({
302 feepart => $self->feepart,
304 # no sdate/edate, right?
309 if ( $maximum and $self->taxable ) {
310 warn "Estimating taxes on fee.\n";
311 # then we need to estimate tax to respect the maximum
312 # XXX currently doesn't work with external (tax_rate) taxes
313 # or batch taxes, obviously
314 my $taxlisthash = {};
315 my $error = $cust_main->_handle_taxes(
318 location => $cust_main->ship_location
321 # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
322 my @taxes = map { $_->[0] } values %$taxlisthash;
324 $total_rate += $_->tax;
326 if ($total_rate > 0) {
327 my $max_cents = $maximum * 100;
328 my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
329 $maximum = sprintf('%.2f', $charge_cents / 100.00);
330 $amount = $maximum if $amount > $maximum;
332 } # if $maximum and $self->taxable
334 # set the amount that we'll charge
335 $cust_bill_pkg->set( $self->setuprecur, $amount );
337 if ( $self->classnum ) {
338 my $pkg_category = $self->pkg_class->pkg_category;
339 $cust_bill_pkg->set('section' => $pkg_category->categoryname)
343 # if this is a percentage fee and has line item fractions,
344 # adjust them to be proportional and to add up correctly.
346 my $cents = $amount * 100;
347 # not necessarily the same as percent
348 my $multiplier = $amount / $total_base;
349 for (my $i = 0; $i < scalar(@items); $i++) {
350 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
351 $item_fee[$i] = $fee;
352 $cents -= $fee * 100;
354 # correct rounding error
355 while ($cents >= 0.5 or $cents < -0.5) {
356 foreach my $fee (@item_fee) {
357 if ( $cents >= 0.5 ) {
360 } elsif ( $cents < -0.5 ) {
366 # and add them to the cust_bill_pkg
367 for (my $i = 0; $i < scalar(@items); $i++) {
368 if ( $item_fee[$i] > 0 ) {
369 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
370 cust_bill_pkg => $cust_bill_pkg,
371 base_invnum => $cust_bill->invnum,
372 amount => $item_fee[$i],
373 base_cust_bill_pkg => $items[$i], # late resolve
377 } else { # if !@item_base
378 # then this isn't a proportional fee, so it just applies to the
380 # (if it's the current invoice, $cust_bill->invnum is null and that
381 # will be fixed later)
382 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
383 cust_bill_pkg => $cust_bill_pkg,
384 base_invnum => $cust_bill->invnum,
389 # cust_bill_pkg::insert will handle this
390 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
391 # avoid misbehavior by usage() and some other things
392 $cust_bill_pkg->set('details', []);
394 return $cust_bill_pkg;
397 =item itemdesc_locale LOCALE
399 Returns a customer-viewable description of this fee for the given locale,
400 from the part_fee_msgcat table. If the locale is empty or no localized fee
401 description exists, returns part_fee.itemdesc.
405 sub itemdesc_locale {
406 my ( $self, $locale ) = @_;
407 return $self->itemdesc unless $locale;
408 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
409 feepart => $self->feepart,
411 }) or return $self->itemdesc;
412 $part_fee_msgcat->itemdesc;
415 =item tax_rates DATA_PROVIDER, GEOCODE
417 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
418 fee, in the location specified by GEOCODE.
424 my ($vendor, $geocode) = @_;
425 return unless $self->taxproductnum;
426 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
428 my @taxclassnums = map { $_->taxclassnum }
429 $taxproduct->part_pkg_taxrate($geocode);
430 return unless @taxclassnums;
432 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
434 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
435 my @taxes = qsearch({ 'table' => 'tax_rate',
436 'hashref' => { 'geocode' => $geocode,
437 'data_vendor' => $vendor },
438 'extra_sql' => $extra_sql,
440 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
446 sub part_pkg_taxoverride {} # we don't do overrides here
450 return ($self->taxproductnum ? 1 : 0);