2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
8 use FS::UID qw( dbh myconnect );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearch qsearchs );
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
24 FS::quotation - Object methods for quotation records
30 $record = new FS::quotation \%hash;
31 $record = new FS::quotation { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
43 An FS::quotation object represents a quotation. FS::quotation inherits from
44 FS::Record. The following fields are currently supported:
81 Creates a new quotation. To add the quotation to the database, see L<"insert">.
83 Note that this stores the hash reference, not a distinct copy of the hash it
84 points to. You can ask the object for a copy with the I<hash> method.
88 sub table { 'quotation'; }
89 sub notice_name { 'Quotation'; }
90 sub template_conf { 'quotation_'; }
91 sub has_sections { 1; }
95 Adds this record to the database. If there is an error, returns the error,
96 otherwise returns false.
100 Delete this record from the database.
102 =item replace OLD_RECORD
104 Replaces the OLD_RECORD with this one in the database. If there is an error,
105 returns the error, otherwise returns false.
109 Checks all fields to make sure this is a valid quotation. If there is
110 an error, returns the error, otherwise returns false. Called by the insert
119 $self->ut_numbern('quotationnum')
120 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
121 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
122 || $self->ut_numbern('_date')
123 || $self->ut_enum('disabled', [ '', 'Y' ])
124 || $self->ut_numbern('usernum')
126 return $error if $error;
128 $self->_date(time) unless $self->_date;
130 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
132 return 'prospectnum or custnum must be specified'
133 if ! $self->prospectnum
147 sub cust_bill_pkg { #actually quotation_pkg objects
148 shift->quotation_pkg(@_);
157 sprintf('%.2f', $self->_total('setup') + $self->_total('setup_tax'));
160 =item total_recur [ FREQ ]
166 #=item total_recur [ FREQ ]
167 #my $freq = @_ ? shift : '';
168 sprintf('%.2f', $self->_total('recur') + $self->_total('recur_tax'));
172 my( $self, $method ) = @_;
175 $total += $_->$method() for $self->quotation_pkg;
176 sprintf('%.2f', $total);
182 my $opt = shift || {};
183 if ($opt and !ref($opt)) {
184 die ref($self). '->email called with positional parameters';
187 my $conf = $self->conf;
189 my $from = delete $opt->{from};
191 # this is where we set the From: address
192 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
193 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
194 $self->SUPER::email( {
205 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
208 #my $cust_main = $self->cust_main;
209 #my $name = $cust_main->name;
210 #my $name_short = $cust_main->name_short;
211 #my $invoice_number = $self->invnum;
212 #my $invoice_date = $self->_date_pretty;
217 =item cust_or_prosect
221 sub cust_or_prospect {
223 $self->custnum ? $self->cust_main : $self->prospect_main;
226 =item cust_or_prospect_label_link
228 HTML links to either the customer or prospect.
230 Returns a list consisting of two elements. The first is a text label for the
231 link, and the second is the URL.
235 sub cust_or_prospect_label_link {
236 my( $self, $p ) = @_;
238 if ( my $custnum = $self->custnum ) {
239 my $display_custnum = $self->cust_main->display_custnum;
240 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
242 : ';show=quotations';
244 emt("View this customer (#[_1])",$display_custnum) =>
245 "${p}view/cust_main.cgi?custnum=$custnum$target"
247 } elsif ( my $prospectnum = $self->prospectnum ) {
249 emt("View this prospect (#[_1])",$prospectnum) =>
250 "${p}view/prospect_main.html?$prospectnum"
258 sub _items_sections {
261 my $escape = $opt{escape}; # the only one we care about
263 my %subtotals; # package frequency => subtotal
264 foreach my $pkg ($self->quotation_pkg) {
265 my $recur_freq = $pkg->part_pkg->freq;
266 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
267 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
269 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
272 foreach my $freq (keys %subtotals) {
274 next if $subtotals{$freq} == 0;
277 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
279 if ( $freq eq '0' ) {
280 if ( scalar(keys(%subtotals)) == 1 ) {
281 # there are no recurring packages
282 $desc = $self->mt('Charges');
284 $desc = $self->mt('Setup Charges');
287 $desc = $self->mt('Recurring Charges') . ' - ' .
288 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
292 'description' => &$escape($desc),
293 'sort_weight' => $weight,
295 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
298 return \@sections, [];
301 =item enable_previous
305 sub enable_previous { 0 }
307 =item convert_cust_main
309 If this quotation already belongs to a customer, then returns that customer, as
310 an FS::cust_main object.
312 Otherwise, creates a new customer (FS::cust_main object and record, and
313 associated) based on this quotation's prospect, then orders this quotation's
314 packages as real packages for the customer.
316 If there is an error, returns an error message, otherwise, returns the
317 newly-created FS::cust_main object.
321 sub convert_cust_main {
324 my $cust_main = $self->cust_main;
325 return $cust_main if $cust_main; #already converted, don't again
327 my $oldAutoCommit = $FS::UID::AutoCommit;
328 local $FS::UID::AutoCommit = 0;
331 $cust_main = $self->prospect_main->convert_cust_main;
332 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
333 $dbh->rollback if $oldAutoCommit;
337 $self->prospectnum('');
338 $self->custnum( $cust_main->custnum );
339 my $error = $self->replace || $self->order;
341 $dbh->rollback if $oldAutoCommit;
345 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
351 =item order [ HASHREF ]
353 This method is for use with quotations which are already associated with a customer.
355 Orders this quotation's packages as real packages for the customer.
357 If there is an error, returns an error message, otherwise returns false.
359 If HASHREF is passed, it will be filled with a hash mapping the
360 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
367 my $pkgnum_map = shift || {};
369 tie my %all_cust_pkg, 'Tie::RefHash';
370 foreach my $quotation_pkg ($self->quotation_pkg) {
371 my $cust_pkg = FS::cust_pkg->new;
372 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
374 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
375 $cust_pkg->set( $_, $quotation_pkg->get($_) );
378 # currently only one discount each
379 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
380 if ( $pkg_discount ) {
381 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
384 $all_cust_pkg{$cust_pkg} = []; # no services
387 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
389 foreach my $quotationpkgnum (keys %$pkgnum_map) {
390 # convert the objects to just pkgnums
391 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
392 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
400 One-time charges, like FS::cust_main::charge()
404 #super false laziness w/cust_main::charge
407 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
408 my ( $pkg, $comment, $additional );
409 my ( $setuptax, $taxclass ); #internal taxes
410 my ( $taxproduct, $override ); #vendor (CCH) taxes
412 my $cust_pkg_ref = '';
413 my ( $bill_now, $invoice_terms ) = ( 0, '' );
415 if ( ref( $_[0] ) ) {
416 $amount = $_[0]->{amount};
417 $setup_cost = $_[0]->{setup_cost};
418 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
419 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
420 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
421 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
422 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
423 : '$'. sprintf("%.2f",$amount);
424 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
425 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
426 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
427 $additional = $_[0]->{additional} || [];
428 $taxproduct = $_[0]->{taxproductnum};
429 $override = { '' => $_[0]->{tax_override} };
430 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
431 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
432 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
433 $locationnum = $_[0]->{locationnum};
439 $pkg = @_ ? shift : 'One-time charge';
440 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
442 $taxclass = @_ ? shift : '';
446 local $SIG{HUP} = 'IGNORE';
447 local $SIG{INT} = 'IGNORE';
448 local $SIG{QUIT} = 'IGNORE';
449 local $SIG{TERM} = 'IGNORE';
450 local $SIG{TSTP} = 'IGNORE';
451 local $SIG{PIPE} = 'IGNORE';
453 my $oldAutoCommit = $FS::UID::AutoCommit;
454 local $FS::UID::AutoCommit = 0;
457 my $part_pkg = new FS::part_pkg ( {
459 'comment' => $comment,
463 'classnum' => ( $classnum ? $classnum : '' ),
464 'setuptax' => $setuptax,
465 'taxclass' => $taxclass,
466 'taxproductnum' => $taxproduct,
467 'setup_cost' => $setup_cost,
470 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
471 ( 0 .. @$additional - 1 )
473 'additional_count' => scalar(@$additional),
474 'setup_fee' => $amount,
477 my $error = $part_pkg->insert( options => \%options,
478 tax_overrides => $override,
481 $dbh->rollback if $oldAutoCommit;
485 my $pkgpart = $part_pkg->pkgpart;
488 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
490 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
491 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
492 $error = $type_pkgs->insert;
494 $dbh->rollback if $oldAutoCommit;
499 #except for DIFF, eveything above is idential to cust_main version
500 #but below is our own thing pretty much (adding a quotation package instead
501 # of ordering a customer package, no "bill now")
503 my $quotation_pkg = new FS::quotation_pkg ( {
504 'quotationnum' => $self->quotationnum,
505 'pkgpart' => $pkgpart,
506 'quantity' => $quantity,
507 #'start_date' => $start_date,
508 #'no_auto' => $no_auto,
509 'locationnum'=> $locationnum,
512 $error = $quotation_pkg->insert;
514 $dbh->rollback if $oldAutoCommit;
516 #} elsif ( $cust_pkg_ref ) {
517 # ${$cust_pkg_ref} = $cust_pkg;
520 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
527 Disables this quotation (sets disabled to Y, which hides the quotation on
528 prospects and customers).
530 If there is an error, returns an error message, otherwise returns false.
536 $self->disabled('Y');
542 Enables this quotation.
544 If there is an error, returns an error message, otherwise returns false.
556 Calculates current prices for all items on this quotation, including
557 discounts and taxes, and updates the quotation_pkg records accordingly.
563 my $conf = FS::Conf->new;
565 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
567 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
569 my @return_bill = ([]);
572 ###### BEGIN TRANSACTION ######
575 my $temp_dbh = myconnect();
576 local $FS::UID::dbh = $temp_dbh;
577 local $FS::UID::AutoCommit = 0;
579 my $fake_self = FS::quotation->new({ $self->hash });
581 # if this is a prospect, make them into a customer for now
582 # XXX prospects currently can't have service locations
583 my $cust_or_prospect = $self->cust_or_prospect;
585 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
586 $cust_main = $cust_or_prospect->convert_cust_main;
587 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
588 $fake_self->set('prospectnum', '');
589 $fake_self->set('custnum', $cust_main->custnum);
591 $cust_main = $cust_or_prospect;
595 $error = $fake_self->order(\%pkgnum_of);
596 die "$error (simulating package order)\n" if $error;
598 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
600 # simulate the first bill
603 'pkg_list' => \@new_pkgs,
604 'time' => time, # an option to adjust this?
605 'return_bill' => $return_bill[0],
606 'no_usage_reset' => 1,
608 $error = $cust_main->bill(%bill_opt);
609 die "$error (simulating initial billing)\n" if $error;
611 # pick dates for future bills
613 foreach (@new_pkgs) {
614 my $bill = $_->get('bill');
616 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
620 foreach my $next_bill (keys %next_bill_pkgs) {
621 $bill_opt{'time'} = $next_bill;
622 $bill_opt{'return_bill'} = $return_bill[$i] = [];
623 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
624 $error = $cust_main->bill(%bill_opt);
625 die "$error (simulating recurring billing cycle $i)\n" if $error;
632 ###### END TRANSACTION ######
633 my %quotationpkgnum_of = reverse %pkgnum_of;
636 warn "pkgnums:\n".Dumper(\%pkgnum_of);
637 warn Dumper(\@return_bill);
640 # Careful: none of the foreign keys in here are correct outside the sandbox.
641 # We have a translation table for pkgnums; all others are total lies.
643 my %quotation_pkg; # quotationpkgnum => quotation_pkg
644 foreach my $qp ($self->quotation_pkg) {
645 $quotation_pkg{$qp->quotationpkgnum} = $qp;
646 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
647 $qp->set('freq', '');
648 # flush old tax records
649 foreach ($qp->quotation_pkg_tax) {
651 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
656 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
657 my %quotation_pkg_discount; # quotationpkgnum => quotation_pkg_discount obj
659 for (my $i = 0; $i < scalar(@return_bill); $i++) {
660 my $this_bill = $return_bill[$i]->[0];
662 warn "$me billing cycle $i produced no invoice\n";
668 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
669 my $pkgnum = $cust_bill_pkg->pkgnum;
670 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
672 # taxes/fees; come back to it
673 push @nonpkg_lines, $cust_bill_pkg;
676 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
677 my $qp = $quotation_pkg{$quotationpkgnum};
679 # XXX supplemental packages could do this (they have separate pkgnums)
680 # handle that special case at some point
681 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
685 # then this is the first (setup) invoice
686 $qp->set('start_date', $cust_bill_pkg->sdate);
687 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
688 # pkgpart_override is a possibility
690 # recurring invoice (should be only one of these per package, though
691 # it may have multiple lineitems with the same pkgnum)
692 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
696 if ( $cust_bill_pkg->get('discounts') ) {
697 my $discount = $cust_bill_pkg->get('discounts')->[0];
699 # discount records are generated as (setup, recur).
700 # well, not always, sometimes it's just (recur), but fixing this
701 # is horribly invasive.
702 my $qpd = $quotation_pkg_discount{$quotationpkgnum}
703 ||= qsearchs('quotation_pkg_discount', {
704 'quotationpkgnum' => $quotationpkgnum
707 if (!$qpd) { #can't happen
708 warn "$me simulated bill returned a discount but no discount is in effect.\n";
710 if ($discount and $qpd) {
712 $qpd->set('setup_amount', $discount->amount);
714 $qpd->set('recur_amount', $discount->amount);
718 } # end of discount stuff
723 foreach my $cust_bill_pkg (@nonpkg_lines) {
725 my $itemdesc = $cust_bill_pkg->itemdesc;
727 if ($cust_bill_pkg->feepart) {
728 warn "$me simulated bill included a non-package fee (feepart ".
729 $cust_bill_pkg->feepart.")\n";
732 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
733 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
735 # breadth-first unrolled recursion:
736 # take each tax link and any tax-on-tax descendants, and merge them
737 # into a single quotation_pkg_tax record for each pkgnum/taxname
739 while (my $tax_link = shift @$links) {
740 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
741 or die "$me unable to resolve tax link\n";
742 if ($target->pkgnum) {
743 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
744 # create this if there isn't one yet
745 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
746 FS::quotation_pkg_tax->new({
747 quotationpkgnum => $quotationpkgnum,
748 itemdesc => $itemdesc,
752 if ( $i == 0 ) { # first invoice
753 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
754 } else { # subsequent invoices
755 # this isn't perfectly accurate, but that's why it's an estimate
756 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
757 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
758 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
760 } elsif ($target->feepart) {
761 # do nothing; we already warned for the fee itself
763 # tax on tax: the tax target is another tax item.
764 # since this is an estimate, I'm just going to assign it to the
765 # first of the underlying packages. (RT#5243 is why we can't have
767 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
768 if ($sublinks and $sublinks->[0]) {
769 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
770 push @$links, $tax_link; #try again
772 warn "$me unable to assign tax on tax; ignoring\n";
775 } # while my $tax_link
777 } # foreach my $cust_bill_pkg
779 foreach my $quotation_pkg (values %quotation_pkg) {
780 $error = $quotation_pkg->replace;
781 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
784 foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
785 $error = $quotation_pkg_discount->replace;
786 return "$error (recording estimated discount)"
789 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
790 $error = $quotation_pkg_tax->insert;
791 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
804 =item search_sql_where HASHREF
806 Class method which returns an SQL WHERE fragment to search for parameters
807 specified in HASHREF. Valid parameters are
813 List reference of start date, end date, as UNIX timestamps.
823 List reference of charged limits (exclusive).
827 List reference of charged limits (exclusive).
831 flag, return open invoices only
835 flag, return net invoices only
843 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
847 sub search_sql_where {
848 my($class, $param) = @_;
850 # warn "$me search_sql_where called with params: \n".
851 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
857 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
858 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
862 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
863 # push @search, "cust_main.refnum = $1";
867 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
868 push @search, "quotation.prospectnum = $1";
872 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
873 push @search, "cust_bill.custnum = $1";
877 if ( $param->{_date} ) {
878 my($beginning, $ending) = @{$param->{_date}};
880 push @search, "quotation._date >= $beginning",
881 "quotation._date < $ending";
885 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
886 push @search, "quotation.quotationnum >= $1";
888 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
889 push @search, "quotation.quotationnum <= $1";
893 # if ( $param->{charged} ) {
894 # my @charged = ref($param->{charged})
895 # ? @{ $param->{charged} }
896 # : ($param->{charged});
898 # push @search, map { s/^charged/cust_bill.charged/; $_; }
902 my $owed_sql = FS::cust_bill->owed_sql;
905 push @search, "quotation._date < ". (time-86400*$param->{'days'})
908 #agent virtualization
909 my $curuser = $FS::CurrentUser::CurrentUser;
910 #false laziness w/search/quotation.html
911 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
912 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
915 join(' AND ', @search );
921 Return line item hashes for each package on this quotation.
926 my ($self, %options) = @_;
927 my $escape = $options{'escape_function'};
928 my $locale = $self->cust_or_prospect->locale;
930 my $preref = $options{'preref_callback'};
932 my $section = $options{'section'};
933 my $freq = $section->{'category'};
934 my @pkgs = $self->quotation_pkg;
936 die "_items_pkg called without section->{'category'}"
937 unless defined $freq;
939 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
940 # like we should have done in the first place
942 foreach my $quotation_pkg (@pkgs) {
943 my $part_pkg = $quotation_pkg->part_pkg;
946 'pkgnum' => $quotation_pkg->quotationpkgnum,
947 'description' => $quotation_pkg->desc($locale),
948 'ext_description' => [],
949 'quantity' => $quotation_pkg->quantity,
953 $setuprecur = 'setup';
954 if ($part_pkg->freq ne '0') {
955 # indicate that it's a setup fee on a recur package (cust_bill does
957 $this_item->{'description'} .= ' Setup';
960 # recur for this frequency
961 next if $freq ne $part_pkg->freq;
962 $setuprecur = 'recur';
965 $this_item->{'unit_amount'} = sprintf('%.2f',
966 $quotation_pkg->get('unit'.$setuprecur));
967 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
968 * $quotation_pkg->quantity);
969 next if $this_item->{'amount'} == 0;
972 $this_item->{'preref_html'} = &$preref($quotation_pkg);
975 push @items, $this_item;
976 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
978 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
979 push @items, $discount;
982 # each quotation_pkg_tax has two amounts: the amount charged on the
983 # setup invoice, and the amount on the recurring invoice.
984 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
985 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
987 'description' => $qpt->itemdesc,
988 'ext_description' => [],
991 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
993 } # foreach $quotation_pkg
995 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
996 my $this_tax = $tax_item{$taxname};
997 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
998 next if $this_tax->{'amount'} == 0;
999 push @items, $this_tax;
1015 L<FS::Record>, schema.html from the base documentation.