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
130 $self->ut_numbern('feepart')
131 || $self->ut_textn('comment')
132 || $self->ut_flag('disabled')
133 || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
134 || $self->ut_flag('taxable')
135 || $self->ut_textn('taxclass')
136 || $self->ut_numbern('taxproductnum')
137 || $self->ut_floatn('pay_weight')
138 || $self->ut_floatn('credit_weight')
139 || $self->ut_agentnum_acl('agentnum',
140 [ 'Edit global package definitions' ])
141 || $self->ut_moneyn('amount')
142 || $self->ut_floatn('percent')
143 || $self->ut_moneyn('minimum')
144 || $self->ut_moneyn('maximum')
145 || $self->ut_flag('limit_credit')
146 || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
147 || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
149 return $error if $error;
151 return "For a percentage fee, the basis must be set"
152 if $self->get('percent') > 0 and $self->get('basis') eq '';
154 if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
155 # then it makes no sense to apply minimum/maximum
156 $self->set('minimum', '');
157 $self->set('maximum', '');
159 if ( $self->get('limit_credit') ) {
160 $self->set('maximum', '');
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';
194 if ( $self->minimum or $self->maximum or $self->limit_credit ) {
196 if ( $self->minimum ) {
197 $string .= ' at least '.sprintf($money, $self->minimum);
199 if ( $self->maximum ) {
200 $string .= ' and' if $self->minimum;
201 $string .= ' at most '.sprintf($money, $self->maximum);
203 if ( $self->limit_credit ) {
204 if ( $self->maximum ) {
205 $string .= ", or the customer's credit balance, whichever is less.";
207 $string .= ' and' if $self->minimum;
208 $string .= " not more than the customer's credit balance";
215 =item lineitem INVOICE
217 Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object
218 representing the invoice line item for the fee, with linked
219 L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or
220 its line items, as appropriate.
226 my $cust_bill = shift;
228 my $amount = 0 + $self->get('amount');
229 my $total_base; # sum of base line items
230 my @items; # base line items (cust_bill_pkg records)
231 my @item_base; # charged/owed of that item (sequential w/ @items)
232 my @item_fee; # fee amount of that item (sequential w/ @items)
233 my @cust_bill_pkg_fee; # link record
235 warn "Calculating fee: ".$self->itemdesc." on ".
236 ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
238 if ( $self->percent > 0 and $self->basis ne '' ) {
239 warn $self->percent . "% of amount ".$self->basis.")\n"
242 # $total_base: the total charged/owed on the invoice
243 # %item_base: billpkgnum => fraction of base amount
244 if ( $cust_bill->invnum ) {
245 my $basis = $self->basis;
246 $total_base = $cust_bill->$basis; # "charged", "owed"
248 # calculate the fee on an already-inserted past invoice. This may have
249 # payments or credits, so if basis = owed, we need to consider those.
250 my $basis_sql = $basis.'_sql';
251 my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
252 ' FROM cust_bill_pkg WHERE billpkgnum = ?';
253 @items = $cust_bill->cust_bill_pkg;
254 @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
257 # the fee applies to _this_ invoice. It has no payments or credits, so
258 # "charged" and "owed" basis are both just the invoice amount, and
259 # the line item amounts (setup + recur)
260 $total_base = $cust_bill->charged;
261 @items = @{ $cust_bill->get('cust_bill_pkg') };
262 @item_base = map { $_->setup + $_->recur }
266 $amount += $total_base * $self->percent / 100;
269 if ( $self->minimum ne '' and $amount < $self->minimum ) {
270 warn "Applying mininum fee\n" if $DEBUG;
271 $amount = $self->minimum;
274 my $maximum = $self->maximum;
275 if ( $self->limit_credit ) {
276 my $balance = $cust_bill->cust_main;
277 if ( $balance >= 0 ) {
279 } elsif ( -1 * $balance < $maximum ) {
280 $maximum = -1 * $balance;
283 if ( $maximum ne '' and $amount > $maximum ) {
284 warn "Applying maximum fee\n" if $DEBUG;
288 # at this point, if the fee is zero, return nothing
289 return if $amount < 0.005;
290 $amount = sprintf('%.2f', $amount);
292 my $cust_bill_pkg = FS::cust_bill_pkg->new({
293 feepart => $self->feepart,
295 # no sdate/edate, right?
299 $cust_bill_pkg->set( $self->setuprecur, $amount );
301 if ( $self->classnum ) {
302 my $pkg_category = $self->pkg_class->pkg_category;
303 $cust_bill_pkg->set('section' => $pkg_category->categoryname)
307 # if this is a percentage fee and has line item fractions,
308 # adjust them to be proportional and to add up correctly.
310 my $cents = $amount * 100;
311 # not necessarily the same as percent
312 my $multiplier = $amount / $total_base;
313 for (my $i = 0; $i < scalar(@items); $i++) {
314 my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
315 $item_fee[$i] = $fee;
316 $cents -= $fee * 100;
318 # correct rounding error
319 while ($cents >= 0.5 or $cents < -0.5) {
320 foreach my $fee (@item_fee) {
321 if ( $cents >= 0.5 ) {
324 } elsif ( $cents < -0.5 ) {
330 # and add them to the cust_bill_pkg
331 for (my $i = 0; $i < scalar(@items); $i++) {
332 if ( $item_fee[$i] > 0 ) {
333 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
334 cust_bill_pkg => $cust_bill_pkg,
335 base_invnum => $cust_bill->invnum,
336 amount => $item_fee[$i],
337 base_cust_bill_pkg => $items[$i], # late resolve
341 } else { # if !@item_base
342 # then this isn't a proportional fee, so it just applies to the
344 # (if it's the current invoice, $cust_bill->invnum is null and that
345 # will be fixed later)
346 push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
347 cust_bill_pkg => $cust_bill_pkg,
348 base_invnum => $cust_bill->invnum,
353 # cust_bill_pkg::insert will handle this
354 $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
355 # avoid misbehavior by usage() and some other things
356 $cust_bill_pkg->set('details', []);
358 return $cust_bill_pkg;
361 =item itemdesc_locale LOCALE
363 Returns a customer-viewable description of this fee for the given locale,
364 from the part_fee_msgcat table. If the locale is empty or no localized fee
365 description exists, returns part_fee.itemdesc.
369 sub itemdesc_locale {
370 my ( $self, $locale ) = @_;
371 return $self->itemdesc unless $locale;
372 my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
373 feepart => $self->feepart,
375 }) or return $self->itemdesc;
376 $part_fee_msgcat->itemdesc;
379 =item tax_rates DATA_PROVIDER, GEOCODE
381 Returns the external taxes (L<FS::tax_rate> objects) that apply to this
382 fee, in the location specified by GEOCODE.
388 my ($vendor, $geocode) = @_;
389 return unless $self->taxproductnum;
390 my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
392 my @taxclassnums = map { $_->taxclassnum }
393 $taxproduct->part_pkg_taxrate($geocode);
394 return unless @taxclassnums;
396 warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
398 my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
399 my @taxes = qsearch({ 'table' => 'tax_rate',
400 'hashref' => { 'geocode' => $geocode,
401 'data_vendor' => $vendor },
402 'extra_sql' => $extra_sql,
404 warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
410 sub part_pkg_taxoverride {} # we don't do overrides here
414 return ($self->taxproductnum ? 1 : 0);