'contract_end', @date_type, '', '',
'quantity', 'int', 'NULL', '', '', '',
'waive_setup', 'char', 'NULL', 1, '', '',
+ 'unitsetup', @money_typen, '', '',
+ 'unitrecur', @money_typen, '', '',
],
'primary_key' => 'quotationpkgnum',
'unique' => [],
'quotationpkgdiscountnum', 'serial', '', '', '', '',
'quotationpkgnum', 'int', '', '', '', '',
'discountnum', 'int', '', '', '', '',
+ 'setup_amount', @money_typen, '', '',
+ 'recur_amount', @money_typen, '', '',
#'end_date', @date_type, '', '',
],
'primary_key' => 'quotationpkgdiscountnum',
}
-=item cust_bill_pkg_discount
+=item pkg_discount
-Returns the list of associated cust_bill_pkg_discount objects.
+Returns the list of associated cust_bill_pkg_discount or
+quotation_pkg_discount objects.
=cut
-sub cust_bill_pkg_discount {
+sub pkg_discount {
my $self = shift;
- qsearch( $self->discount_table, { 'billpkgnum' => $self->billpkgnum } );
+ my $pkey = $self->primary_key;
+ qsearch( $self->discount_table, { $pkey => $self->get($pkey) } );
}
1;
# (this is used in the summary & on the payment coupon)
$invoice_data{'balance'} = sprintf("%.2f", $balance_due);
- # info from customer's last invoice before this one, for some
- # summary formats
- $invoice_data{'last_bill'} = {};
+ # flag telling this invoice to have a first-page summary
+ my $summarypage = '';
if ( $self->custnum && $self->invnum ) {
+ # XXX should be an FS::cust_bill method to set the defaults, instead
+ # of checking the type here
my $last_bill = $self->previous_bill;
if ( $last_bill ) {
$invoice_data{'previous_payments'} = [];
$invoice_data{'previous_credits'} = [];
}
- } # if this is an invoice
- my $summarypage = '';
- if ( $conf->exists('invoice_usesummary', $agentnum) ) {
- $summarypage = 1;
- }
- $invoice_data{'summarypage'} = $summarypage;
+ # info from customer's last invoice before this one, for some
+ # summary formats
+ $invoice_data{'last_bill'} = {};
+
+ if ( $conf->exists('invoice_usesummary', $agentnum) ) {
+ $invoice_data{'summarypage'} = $summarypage = 1;
+ }
+
+ } # if this is an invoice
warn "$me substituting variables in notes, footer, smallfooter\n"
if $DEBUG > 1;
join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
if $DEBUG > 1;
+ push @buf, ( [ $line_item->{'description'},
+ $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+ ],
+ map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
+ );
+
$line_item->{'ref'} = $line_item->{'pkgnum'};
$line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
$line_item->{'section'} = $section;
$line_item->{'ext_description'} ||= [];
push @detail_items, $line_item;
- push @buf, ( [ $line_item->{'description'},
- $money_char. sprintf("%10.2f", $line_item->{'amount'}),
- ],
- map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
- );
}
if ( $section->{'description'} ) {
if $cust_bill_pkg->recur != 0
|| $discount_show_always
|| $cust_bill_pkg->recur_show_zero;
- push @b, {
+ #push @b, {
+ # keep it consistent, please
+ $s = {
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => $description,
'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
};
}
if ( $cust_bill_pkg->recur != 0 ) {
- push @b, {
+ #push @b, {
+ $r = {
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
} # recurring or usage with recurring charge
- # decide whether to show active discounts here
- if (
- # case 1: we are showing a single line for the package
- ( !$type )
- # case 2: we are showing a setup line for a package that has
- # no base recurring fee
- or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
- # case 3: we are showing a recur line for a package that has
- # a base recurring fee
- or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
- ) {
-
- # the line item hashref for the line that will show the original
- # price
- # (use the recur or single line for the package, unless we're
- # showing a setup line for a package with no recurring fee)
- my $active_line = $r;
- if ( $type eq 'S' ) {
- $active_line = $s;
- }
-
- my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
- # special case: if there are old "discount details" on this line
- # item, don't show discount line items
- if ( FS::cust_bill_pkg_detail->count(
- "detail LIKE 'Includes discount%' AND billpkgnum = " .
- $cust_bill_pkg->billpkgnum
- ) > 0 ) {
- @discounts = ();
- }
- if ( @discounts ) {
- warn "$me _items_cust_bill_pkg including discounts for ".
- $cust_bill_pkg->billpkgnum."\n"
- if $DEBUG;
- my $discount_amount = sum( map {$_->amount} @discounts );
- # if multiple discounts apply to the same package, how to display
- # them? ext_description lines, apparently
- #
- # # discount amounts are negative
- if ( $d and $cust_bill_pkg->hidden ) {
- $d->{amount} -= $discount_amount;
- } else {
- my @ext;
- $d = {
- _is_discount => 1,
- description => $self->mt('Discount'),
- amount => -1 * $discount_amount,
- ext_description => \@ext,
- };
- foreach my $cust_bill_pkg_discount (@discounts) {
- my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount;
- push @ext, &{$escape_function}( $def->description );
- }
- }
-
- # update the active line (before the discount) to show the
- # original price (whether this is a hidden line or not)
- $active_line->{amount} += $discount_amount;
-
- } # if there are any discounts
- } # if this is an appropriate place to show discounts
-
} else { # taxes and fees
warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
} # if quotation / package line item / other line item
+ # decide whether to show active discounts here
+ if (
+ # case 1: we are showing a single line for the package
+ ( !$type )
+ # case 2: we are showing a setup line for a package that has
+ # no base recurring fee
+ or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
+ # case 3: we are showing a recur line for a package that has
+ # a base recurring fee
+ or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
+ ) {
+
+ my $item_discount = $cust_bill_pkg->_item_discount;
+ if ( $item_discount ) {
+ # $item_discount->{amount} is negative
+
+ if ( $d and $cust_bill_pkg->hidden ) {
+ $d->{amount} += $item_discount->{amount};
+ } else {
+ $d = $item_discount;
+ $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
+ }
+
+ # update the active line (before the discount) to show the
+ # original price (whether this is a hidden line or not)
+ #
+ # quotation discounts keep track of setup and recur; invoice
+ # discounts currently don't
+ if ( exists $item_discount->{setup_amount} ) {
+
+ $s->{amount} -= $item_discount->{setup_amount} if $s;
+ $r->{amount} -= $item_discount->{recur_amount} if $r;
+
+ } else {
+
+ # $active_line is the line item hashref for the line that will
+ # show the original price
+ # (use the recur or single line for the package, unless we're
+ # showing a setup line for a package with no recurring fee)
+ my $active_line = $r;
+ if ( $type eq 'S' ) {
+ $active_line = $s;
+ }
+ $active_line->{amount} -= $item_discount->{amount};
+
+ }
+
+ } # if there are any discounts
+ } # if this is an appropriate place to show discounts
+
} # foreach $display
$discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
$self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
}
+=item _item_discount
+
+If this item has any discounts, returns a hashref in the format used
+by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
+on an invoice. This will contain the keys 'description', 'amount',
+'ext_description' (an arrayref of text lines describing the discounts),
+and '_is_discount' (a flag).
+
+The value for 'amount' will be negative, and will be scaled for the package
+quantity.
+
+=cut
+
+sub _item_discount {
+ my $self = shift;
+ my @pkg_discounts = $self->pkg_discount;
+ return if @pkg_discounts == 0;
+ # special case: if there are old "discount details" on this line item, don't
+ # show discount line items
+ if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
+ return;
+ }
+
+ my @ext;
+ my $d = {
+ _is_discount => 1,
+ description => $self->mt('Discount'),
+ amount => 0,
+ ext_description => \@ext,
+ # maybe should show quantity/unit discount?
+ };
+ foreach my $pkg_discount (@pkg_discounts) {
+ push @ext, $pkg_discount->description;
+ $d->{amount} -= $pkg_discount->amount;
+ }
+ $d->{amount} *= $self->quantity || 1;
+
+ return $d;
+}
=item set_display OPTION => VALUE ...
}
+=item description
+
+Returns a string describing the discount (for use on an invoice).
+
+=cut
+
+sub description {
+ my $self = shift;
+ my $discount = $self->cust_pkg_discount->discount;
+ my $desc = $discount->description_short;
+ $desc .= $self->mt(' each') if $self->cust_bill_pkg->quantity > 1;
+
+ if ($discount->months) {
+ # calculate months remaining on this cust_pkg_discount after this invoice
+ my $date = $self->cust_bill_pkg->cust_bill->_date;
+ my $used = FS::Record->scalar_sql(
+ 'SELECT SUM(months) FROM cust_bill_pkg_discount
+ JOIN cust_bill_pkg USING (billpkgnum)
+ JOIN cust_bill USING (invnum)
+ WHERE pkgdiscountnum = ? AND _date <= ?',
+ $self->pkgdiscountnum,
+ $date
+ );
+ $used ||= 0;
+ my $remaining = sprintf('%.2f', $discount->months - $used);
+ $desc .= $self->mt(' for [quant,_1,month] ([quant,_2,month] remaining)',
+ $self->months,
+ $remaining
+ );
+ }
+ return $desc;
+}
+
=back
=head1 BUGS
}
+=item _items_pkg
+
+Return line item hashes for each package on this quotation. Differs from the
+base L<FS::Template_Mixin> version in that it recalculates each quoted package
+first, and doesn't implement the "condensed" option.
+
+=cut
+
+sub _items_pkg {
+ my ($self, %options) = @_;
+ my @quotation_pkg = $self->quotation_pkg;
+ foreach (@quotation_pkg) {
+ my $error = $_->estimate;
+ die "error calculating estimate for pkgpart " . $_->pkgpart.": $error\n"
+ if $error;
+ }
+
+ # run it through the Template_Mixin engine
+ return $self->_items_cust_bill_pkg(\@quotation_pkg, %options);
+}
+
=back
=head1 BUGS
use strict;
use base qw( FS::TemplateItem_Mixin FS::Record );
-use FS::Record qw( qsearchs ); #qsearch
+use FS::Record qw( qsearchs dbh ); #qsearch
use FS::part_pkg;
use FS::cust_location;
use FS::quotation;
use FS::quotation_pkg_discount; #so its loaded when TemplateItem_Mixin needs it
+use List::Util qw(sum);
=head1 NAME
=item pkgpart
-pkgpart
+pkgpart (L<FS::part_pkg>) of the package
=item locationnum
-locationnum
+locationnum (L<FS::cust_location>) where the package will be in service
=item start_date
-start_date
+expected start date for the package, as a timestamp
=item contract_end
-contract_end
+contract end date
=item quantity
=item waive_setup
-waive_setup
+'Y' to waive the setup fee
+=item unitsetup
+
+The amount per package that will be charged in setup/one-time fees.
+
+=item unitrecur
+
+The amount per package that will be charged per billing cycle.
=back
Adds this record to the database. If there is an error, returns the error,
otherwise returns false.
+=cut
+
+sub insert {
+ my ($self, %options) = @_;
+
+ my $dbh = dbh;
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+
+ my $error = $self->SUPER::insert;
+
+ if ( !$error and $self->discountnum ) {
+ $error = $self->insert_discount;
+ $error .= ' (setting discount)' if $error;
+ }
+
+ # update $self and any discounts with their amounts
+ if ( !$error ) {
+ $error = $self->estimate;
+ $error .= ' (calculating charges)' if $error;
+ }
+
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ } else {
+ $dbh->commit if $oldAutoCommit;
+ return '';
+ }
+}
+
=item delete
Delete this record from the database.
+=cut
+
+sub delete {
+ my $self = shift;
+
+ my $dbh = dbh;
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+
+ foreach ($self->quotation_pkg_discount) {
+ my $error = $_->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error . ' (deleting discount)';
+ }
+ }
+
+ my $error = $self->SUPER::delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ } else {
+ $dbh->commit if $oldAutoCommit;
+ return '';
+ }
+
+}
+
=item replace OLD_RECORD
Replaces the OLD_RECORD with this one in the database. If there is an error,
|| $self->ut_numbern('start_date')
|| $self->ut_numbern('contract_end')
|| $self->ut_numbern('quantity')
+ || $self->ut_moneyn('unitsetup')
+ || $self->ut_moneyn('unitrecur')
|| $self->ut_enum('waive_setup', [ '', 'Y'] )
;
+
return $error if $error;
$self->SUPER::check;
$self->part_pkg->pkg;
}
-sub setup {
+=item estimate
+
+Update the quotation_pkg record with the estimated setup and recurring
+charges for the package. Returns nothing on success, or an error message
+on failure.
+
+=cut
+
+sub estimate {
my $self = shift;
- return '0.00' if $self->waive_setup eq 'Y' || $self->{'_NO_SETUP_KLUDGE'};
my $part_pkg = $self->part_pkg;
- #my $setup = $part_pkg->can('base_setup') ? $part_pkg->base_setup
- # : $part_pkg->option('setup_fee');
- my $setup = $part_pkg->option('setup_fee');
- #XXX discounts
- $setup *= $self->quantity if $self->quantity;
- sprintf('%.2f', $setup);
+ my $quantity = $self->quantity || 1;
+ my ($unitsetup, $unitrecur);
+ # calculate base fees
+ if ( $self->waive_setup eq 'Y' || $self->{'_NO_SETUP_KLUDGE'} ) {
+ $unitsetup = '0.00';
+ } else {
+ $unitsetup = $part_pkg->base_setup;
+ }
+ if ( $self->{'_NO_RECUR_KLUDGE'} ) {
+ $unitrecur = '0.00';
+ } else {
+ $unitrecur = $part_pkg->base_recur;
+ }
+
+ #XXX add-on packages
+
+ $self->set('unitsetup', $unitsetup);
+ $self->set('unitrecur', $unitrecur);
+ my $error = $self->replace;
+ return $error if $error;
+
+ # semi-duplicates calc_discount
+ my $setup_discount = 0;
+ my $recur_discount = 0;
+
+ my %setup_discounts; # quotationpkgdiscountnum => amount
+ my %recur_discounts; # quotationpkgdiscountnum => amount
+
+ # XXX the order of applying discounts is ill-defined, which matters
+ # if there are percentage and amount discounts on the same package.
+ foreach my $pkg_discount ($self->quotation_pkg_discount) {
+
+ my $discount = $pkg_discount->discount;
+ my $this_setup_discount = 0;
+ my $this_recur_discount = 0;
+
+ if ( $discount->percent > 0 ) {
+
+ if ( $discount->setup ) {
+ $this_setup_discount = ($discount->percent * $unitsetup / 100);
+ }
+ $this_recur_discount = ($discount->percent * $unitrecur / 100);
+
+ } elsif ( $discount->amount > 0 ) {
+
+ my $discount_left = $discount->amount;
+ if ( $discount->setup ) {
+ if ( $discount_left > $unitsetup - $setup_discount ) {
+ # then discount the setup to zero
+ $discount_left -= $unitsetup - $setup_discount;
+ $this_setup_discount = $unitsetup - $setup_discount;
+ } else {
+ # not enough discount to fully cover the setup
+ $this_setup_discount = $discount_left;
+ $discount_left = 0;
+ }
+ }
+ # same logic for recur
+ if ( $discount_left > $unitrecur - $recur_discount ) {
+ $this_recur_discount = $unitrecur - $recur_discount;
+ } else {
+ $this_recur_discount = $discount_left;
+ }
+
+ }
+
+ # increment the total discountage
+ $setup_discount += $this_setup_discount;
+ $recur_discount += $this_recur_discount;
+ # and update the pkg_discount object
+ $pkg_discount->set('setup_amount', sprintf('%.2f', $setup_discount));
+ $pkg_discount->set('recur_amount', sprintf('%.2f', $recur_discount));
+ my $error = $pkg_discount->replace;
+ return $error if $error;
+ }
+ '';
}
-sub recur {
+=item insert_discount
+
+Associates this package with a discount (see L<FS::cust_pkg_discount>,
+possibly inserting a new discount on the fly (see L<FS::discount>). Properties
+of the discount will be taken from this object.
+
+=cut
+
+sub insert_discount {
+ #my ($self, %options) = @_;
my $self = shift;
- return '0.00' if $self->{'_NO_RECUR_KLUDGE'};
- my $part_pkg = $self->part_pkg;
- my $recur = $part_pkg->can('base_recur') ? $part_pkg->base_recur
- : $part_pkg->option('recur_fee');
- #XXX discounts
- $recur *= $self->quantity if $self->quantity;
- sprintf('%.2f', $recur);
+
+ my $cust_pkg_discount = FS::quotation_pkg_discount->new( {
+ 'quotationpkgnum' => $self->quotationpkgnum,
+ 'discountnum' => $self->discountnum,
+ #for the create a new discount case
+ '_type' => $self->discountnum__type,
+ 'amount' => $self->discountnum_amount,
+ 'percent' => $self->discountnum_percent,
+ 'months' => $self->discountnum_months,
+ 'setup' => $self->discountnum_setup,
+ } );
+
+ $cust_pkg_discount->insert;
}
-sub unitsetup {
+sub _item_discount {
my $self = shift;
- return '0.00' if $self->waive_setup eq 'Y' || $self->{'_NO_SETUP_KLUDGE'};
- my $part_pkg = $self->part_pkg;
- my $setup = $part_pkg->option('setup_fee');
+ my @pkg_discounts = $self->pkg_discount;
+ return if @pkg_discounts == 0;
+
+ my @ext;
+ my $d = {
+ _is_discount => 1,
+ description => $self->mt('Discount'),
+ setup_amount => 0,
+ recur_amount => 0,
+ amount => 0,
+ ext_description => \@ext,
+ # maybe should show quantity/unit discount?
+ };
+ foreach my $pkg_discount (@pkg_discounts) {
+ push @ext, $pkg_discount->description;
+ $d->{setup_amount} -= $pkg_discount->setup_amount;
+ $d->{recur_amount} -= $pkg_discount->recur_amount;
+ }
+ $d->{setup_amount} *= $self->quantity || 1;
+ $d->{recur_amount} *= $self->quantity || 1;
+ $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
+
+ return $d;
+}
- #XXX discounts
- sprintf('%.2f', $setup);
+sub setup {
+ my $self = shift;
+ ($self->unitsetup - sum(map { $_->setup_amount } $self->pkg_discount))
+ * ($self->quantity || 1);
}
-sub unitrecur {
+sub recur {
my $self = shift;
- return '0.00' if $self->{'_NO_RECUR_KLUDGE'};
- my $part_pkg = $self->part_pkg;
- my $recur = $part_pkg->can('base_recur') ? $part_pkg->base_recur
- : $part_pkg->option('recur_fee');
- #XXX discounts
- sprintf('%.2f', $recur);
+ ($self->unitrecur - sum(map { $_->recur_amount } $self->pkg_discount))
+ * ($self->quantity || 1);
}
=item cust_bill_pkg_display [ type => TYPE ]
=head1 BUGS
+Doesn't support taxes, fees, or add-on packages.
+
=head1 SEE ALSO
L<FS::Record>, schema.html from the base documentation.
use strict;
use base qw( FS::Record );
use FS::Record qw( qsearch qsearchs );
+use FS::Maketext 'mt';
=head1 NAME
=item quotationpkgnum
-quotationpkgnum
+quotationpkgnum of the L<FS::quotation_pkg> record that this discount is
+for.
=item discountnum
-discountnum
+discountnum (L<FS::discount>)
+=item setup_amount
+
+Amount that will be discounted from setup fees, per package quantity.
+
+=item recur_amount
+
+Amount that will be discounted from recurring fees in the first billing
+cycle, per package quantity.
=back
$self->ut_numbern('quotationpkgdiscountnum')
|| $self->ut_foreign_key('quotationpkgnum', 'quotation_pkg', 'quotationpkgnum' )
|| $self->ut_foreign_key('discountnum', 'discount', 'discountnum' )
+ || $self->ut_moneyn('setup_amount')
+ || $self->ut_moneyn('recur_amount')
;
return $error if $error;
=back
+=item amount
+
+Returns the total amount of this discount (setup + recur), for compatibility
+with L<FS::cust_bill_pkg_discount>.
+
+=cut
+
+sub amount {
+ my $self = shift;
+ return $self->get('setup_amount') + $self->get('recur_amount');
+}
+
+=item description
+
+Returns a string describing the discount (for use on the quotation).
+
+=cut
+
+sub description {
+ my $self = shift;
+ my $discount = $self->discount;
+ my $desc = $discount->description_short;
+ # XXX localize to prospect language, once prospects get languages
+ $desc .= mt(' each') if $self->quotation_pkg->quantity > 1;
+
+ if ($discount->months) {
+ # unlike cust_bill_pkg_discount, there are no "months remaining"; it
+ # hasn't started yet.
+ $desc .= mt(' (for [quant,_1,month])', $discount->months);
+ }
+ return $desc;
+}
+
=head1 BUGS
=head1 SEE ALSO
$quotation_pkg->prospectnum($prospect_main->prospectnum) if $prospect_main;
#XXX handle new location
- $error = $quotation_pkg->insert;
+ $error = $quotation_pkg->insert || $quotation_pkg->estimate;
} else {