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];
698 # discount records are generated as (setup, recur).
699 # well, not always, sometimes it's just (recur), but fixing this
700 # is horribly invasive.
701 my $qpd = $quotation_pkg_discount{$quotationpkgnum}
702 ||= qsearchs('quotation_pkg_discount', {
703 'quotationpkgnum' => $quotationpkgnum
706 if (!$qpd) { #can't happen
707 warn "$me simulated bill returned a discount but no discount is in effect.\n";
709 if ($discount and $qpd) {
711 $qpd->set('setup_amount', $discount->amount);
713 $qpd->set('recur_amount', $discount->amount);
716 } # end of discount stuff
721 foreach my $cust_bill_pkg (@nonpkg_lines) {
723 my $itemdesc = $cust_bill_pkg->itemdesc;
725 if ($cust_bill_pkg->feepart) {
726 warn "$me simulated bill included a non-package fee (feepart ".
727 $cust_bill_pkg->feepart.")\n";
730 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
731 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
733 # breadth-first unrolled recursion:
734 # take each tax link and any tax-on-tax descendants, and merge them
735 # into a single quotation_pkg_tax record for each pkgnum/taxname
737 while (my $tax_link = shift @$links) {
738 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
739 or die "$me unable to resolve tax link\n";
740 if ($target->pkgnum) {
741 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
742 # create this if there isn't one yet
743 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
744 FS::quotation_pkg_tax->new({
745 quotationpkgnum => $quotationpkgnum,
746 itemdesc => $itemdesc,
750 if ( $i == 0 ) { # first invoice
751 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
752 } else { # subsequent invoices
753 # this isn't perfectly accurate, but that's why it's an estimate
754 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
755 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
756 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
758 } elsif ($target->feepart) {
759 # do nothing; we already warned for the fee itself
761 # tax on tax: the tax target is another tax item.
762 # since this is an estimate, I'm just going to assign it to the
763 # first of the underlying packages. (RT#5243 is why we can't have
765 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
766 if ($sublinks and $sublinks->[0]) {
767 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
768 push @$links, $tax_link; #try again
770 warn "$me unable to assign tax on tax; ignoring\n";
773 } # while my $tax_link
775 } # foreach my $cust_bill_pkg
777 foreach my $quotation_pkg (values %quotation_pkg) {
778 $error = $quotation_pkg->replace;
779 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
782 foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
783 $error = $quotation_pkg_discount->replace;
784 return "$error (recording estimated discount)"
787 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
788 $error = $quotation_pkg_tax->insert;
789 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
802 =item search_sql_where HASHREF
804 Class method which returns an SQL WHERE fragment to search for parameters
805 specified in HASHREF. Valid parameters are
811 List reference of start date, end date, as UNIX timestamps.
821 List reference of charged limits (exclusive).
825 List reference of charged limits (exclusive).
829 flag, return open invoices only
833 flag, return net invoices only
841 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
845 sub search_sql_where {
846 my($class, $param) = @_;
848 # warn "$me search_sql_where called with params: \n".
849 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
855 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
856 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
860 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
861 # push @search, "cust_main.refnum = $1";
865 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
866 push @search, "quotation.prospectnum = $1";
870 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
871 push @search, "cust_bill.custnum = $1";
875 if ( $param->{_date} ) {
876 my($beginning, $ending) = @{$param->{_date}};
878 push @search, "quotation._date >= $beginning",
879 "quotation._date < $ending";
883 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
884 push @search, "quotation.quotationnum >= $1";
886 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
887 push @search, "quotation.quotationnum <= $1";
891 # if ( $param->{charged} ) {
892 # my @charged = ref($param->{charged})
893 # ? @{ $param->{charged} }
894 # : ($param->{charged});
896 # push @search, map { s/^charged/cust_bill.charged/; $_; }
900 my $owed_sql = FS::cust_bill->owed_sql;
903 push @search, "quotation._date < ". (time-86400*$param->{'days'})
906 #agent virtualization
907 my $curuser = $FS::CurrentUser::CurrentUser;
908 #false laziness w/search/quotation.html
909 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
910 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
913 join(' AND ', @search );
919 Return line item hashes for each package on this quotation.
924 my ($self, %options) = @_;
925 my $escape = $options{'escape_function'};
926 my $locale = $self->cust_or_prospect->locale;
928 my $preref = $options{'preref_callback'};
930 my $section = $options{'section'};
931 my $freq = $section->{'category'};
932 my @pkgs = $self->quotation_pkg;
934 die "_items_pkg called without section->{'category'}"
935 unless defined $freq;
937 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
938 # like we should have done in the first place
940 foreach my $quotation_pkg (@pkgs) {
941 my $part_pkg = $quotation_pkg->part_pkg;
944 'pkgnum' => $quotation_pkg->quotationpkgnum,
945 'description' => $quotation_pkg->desc($locale),
946 'ext_description' => [],
947 'quantity' => $quotation_pkg->quantity,
951 $setuprecur = 'setup';
952 if ($part_pkg->freq ne '0') {
953 # indicate that it's a setup fee on a recur package (cust_bill does
955 $this_item->{'description'} .= ' Setup';
958 # recur for this frequency
959 next if $freq ne $part_pkg->freq;
960 $setuprecur = 'recur';
963 $this_item->{'unit_amount'} = sprintf('%.2f',
964 $quotation_pkg->get('unit'.$setuprecur));
965 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
966 * $quotation_pkg->quantity);
967 next if $this_item->{'amount'} == 0;
970 $this_item->{'preref_html'} = &$preref($quotation_pkg);
973 push @items, $this_item;
974 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
976 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
977 push @items, $discount;
980 # each quotation_pkg_tax has two amounts: the amount charged on the
981 # setup invoice, and the amount on the recurring invoice.
982 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
983 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
985 'description' => $qpt->itemdesc,
986 'ext_description' => [],
989 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
991 } # foreach $quotation_pkg
993 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
994 my $this_tax = $tax_item{$taxname};
995 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
996 next if $this_tax->{'amount'} == 0;
997 push @items, $this_tax;
1013 L<FS::Record>, schema.html from the base documentation.