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 ######
576 my $temp_dbh = myconnect();
577 local $FS::UID::dbh = $temp_dbh;
578 local $FS::UID::AutoCommit = 0;
580 my $fake_self = FS::quotation->new({ $self->hash });
582 # if this is a prospect, make them into a customer for now
583 # XXX prospects currently can't have service locations
584 my $cust_or_prospect = $self->cust_or_prospect;
586 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
587 $cust_main = $cust_or_prospect->convert_cust_main;
588 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
589 $fake_self->set('prospectnum', '');
590 $fake_self->set('custnum', $cust_main->custnum);
592 $cust_main = $cust_or_prospect;
596 $error = $fake_self->order(\%pkgnum_of);
597 die "$error (simulating package order)\n" if $error;
599 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
601 # simulate the first bill
604 'pkg_list' => \@new_pkgs,
605 'time' => time, # an option to adjust this?
606 'return_bill' => $return_bill[0],
607 'no_usage_reset' => 1,
609 $error = $cust_main->bill(%bill_opt);
610 die "$error (simulating initial billing)\n" if $error;
612 # pick dates for future bills
614 foreach (@new_pkgs) {
615 my $bill = $_->get('bill');
617 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
621 foreach my $next_bill (keys %next_bill_pkgs) {
622 $bill_opt{'time'} = $next_bill;
623 $bill_opt{'return_bill'} = $return_bill[$i] = [];
624 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
625 $error = $cust_main->bill(%bill_opt);
626 die "$error (simulating recurring billing cycle $i)\n" if $error;
633 ###### END TRANSACTION ######
634 my %quotationpkgnum_of = reverse %pkgnum_of;
637 warn "pkgnums:\n".Dumper(\%pkgnum_of);
638 warn Dumper(\@return_bill);
641 # Careful: none of the foreign keys in here are correct outside the sandbox.
642 # We have a translation table for pkgnums; all others are total lies.
644 my %quotation_pkg; # quotationpkgnum => quotation_pkg
645 foreach my $qp ($self->quotation_pkg) {
646 $quotation_pkg{$qp->quotationpkgnum} = $qp;
647 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
648 $qp->set('freq', '');
649 # flush old tax records
650 foreach ($qp->quotation_pkg_tax) {
652 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
657 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
658 my %quotation_pkg_discount; # quotationpkgnum => quotation_pkg_discount obj
660 for (my $i = 0; $i < scalar(@return_bill); $i++) {
661 my $this_bill = $return_bill[$i]->[0];
663 warn "$me billing cycle $i produced no invoice\n";
669 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
670 my $pkgnum = $cust_bill_pkg->pkgnum;
671 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
673 # taxes/fees; come back to it
674 push @nonpkg_lines, $cust_bill_pkg;
677 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
678 my $qp = $quotation_pkg{$quotationpkgnum};
680 # XXX supplemental packages could do this (they have separate pkgnums)
681 # handle that special case at some point
682 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
686 # then this is the first (setup) invoice
687 $qp->set('start_date', $cust_bill_pkg->sdate);
688 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
689 # pkgpart_override is a possibility
691 # recurring invoice (should be only one of these per package, though
692 # it may have multiple lineitems with the same pkgnum)
693 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
697 if ( $cust_bill_pkg->get('discounts') ) {
698 my $discount = $cust_bill_pkg->get('discounts')->[0];
700 # discount records are generated as (setup, recur).
701 # well, not always, sometimes it's just (recur), but fixing this
702 # is horribly invasive.
703 my $qpd = $quotation_pkg_discount{$quotationpkgnum}
704 ||= qsearchs('quotation_pkg_discount', {
705 'quotationpkgnum' => $quotationpkgnum
708 if (!$qpd) { #can't happen
709 warn "$me simulated bill returned a discount but no discount is in effect.\n";
711 if ($discount and $qpd) {
713 $qpd->set('setup_amount', $discount->amount);
715 $qpd->set('recur_amount', $discount->amount);
719 } # end of discount stuff
724 foreach my $cust_bill_pkg (@nonpkg_lines) {
726 my $itemdesc = $cust_bill_pkg->itemdesc;
728 if ($cust_bill_pkg->feepart) {
729 warn "$me simulated bill included a non-package fee (feepart ".
730 $cust_bill_pkg->feepart.")\n";
733 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
734 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
736 # breadth-first unrolled recursion:
737 # take each tax link and any tax-on-tax descendants, and merge them
738 # into a single quotation_pkg_tax record for each pkgnum/taxname
740 while (my $tax_link = shift @$links) {
741 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
742 or die "$me unable to resolve tax link\n";
743 if ($target->pkgnum) {
744 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
745 # create this if there isn't one yet
746 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
747 FS::quotation_pkg_tax->new({
748 quotationpkgnum => $quotationpkgnum,
749 itemdesc => $itemdesc,
753 if ( $i == 0 ) { # first invoice
754 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
755 } else { # subsequent invoices
756 # this isn't perfectly accurate, but that's why it's an estimate
757 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
758 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
759 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
761 } elsif ($target->feepart) {
762 # do nothing; we already warned for the fee itself
764 # tax on tax: the tax target is another tax item.
765 # since this is an estimate, I'm just going to assign it to the
766 # first of the underlying packages. (RT#5243 is why we can't have
768 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
769 if ($sublinks and $sublinks->[0]) {
770 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
771 push @$links, $tax_link; #try again
773 warn "$me unable to assign tax on tax; ignoring\n";
776 } # while my $tax_link
778 } # foreach my $cust_bill_pkg
780 foreach my $quotation_pkg (values %quotation_pkg) {
781 $error = $quotation_pkg->replace;
782 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
785 foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
786 $error = $quotation_pkg_discount->replace;
787 return "$error (recording estimated discount)"
790 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
791 $error = $quotation_pkg_tax->insert;
792 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
805 =item search_sql_where HASHREF
807 Class method which returns an SQL WHERE fragment to search for parameters
808 specified in HASHREF. Valid parameters are
814 List reference of start date, end date, as UNIX timestamps.
824 List reference of charged limits (exclusive).
828 List reference of charged limits (exclusive).
832 flag, return open invoices only
836 flag, return net invoices only
844 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
848 sub search_sql_where {
849 my($class, $param) = @_;
851 # warn "$me search_sql_where called with params: \n".
852 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
858 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
859 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
863 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
864 # push @search, "cust_main.refnum = $1";
868 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
869 push @search, "quotation.prospectnum = $1";
873 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
874 push @search, "cust_bill.custnum = $1";
878 if ( $param->{_date} ) {
879 my($beginning, $ending) = @{$param->{_date}};
881 push @search, "quotation._date >= $beginning",
882 "quotation._date < $ending";
886 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
887 push @search, "quotation.quotationnum >= $1";
889 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
890 push @search, "quotation.quotationnum <= $1";
894 # if ( $param->{charged} ) {
895 # my @charged = ref($param->{charged})
896 # ? @{ $param->{charged} }
897 # : ($param->{charged});
899 # push @search, map { s/^charged/cust_bill.charged/; $_; }
903 my $owed_sql = FS::cust_bill->owed_sql;
906 push @search, "quotation._date < ". (time-86400*$param->{'days'})
909 #agent virtualization
910 my $curuser = $FS::CurrentUser::CurrentUser;
911 #false laziness w/search/quotation.html
912 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
913 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
916 join(' AND ', @search );
922 Return line item hashes for each package on this quotation.
927 my ($self, %options) = @_;
928 my $escape = $options{'escape_function'};
929 my $locale = $self->cust_or_prospect->locale;
931 my $preref = $options{'preref_callback'};
933 my $section = $options{'section'};
934 my $freq = $section->{'category'};
935 my @pkgs = $self->quotation_pkg;
937 die "_items_pkg called without section->{'category'}"
938 unless defined $freq;
940 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
941 # like we should have done in the first place
943 foreach my $quotation_pkg (@pkgs) {
944 my $part_pkg = $quotation_pkg->part_pkg;
947 'pkgnum' => $quotation_pkg->quotationpkgnum,
948 'description' => $quotation_pkg->desc($locale),
949 'ext_description' => [],
950 'quantity' => $quotation_pkg->quantity,
954 $setuprecur = 'setup';
955 if ($part_pkg->freq ne '0') {
956 # indicate that it's a setup fee on a recur package (cust_bill does
958 $this_item->{'description'} .= ' Setup';
961 # recur for this frequency
962 next if $freq ne $part_pkg->freq;
963 $setuprecur = 'recur';
966 $this_item->{'unit_amount'} = sprintf('%.2f',
967 $quotation_pkg->get('unit'.$setuprecur));
968 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
969 * $quotation_pkg->quantity);
970 next if $this_item->{'amount'} == 0;
973 $this_item->{'preref_html'} = &$preref($quotation_pkg);
976 push @items, $this_item;
977 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
979 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
980 push @items, $discount;
983 # each quotation_pkg_tax has two amounts: the amount charged on the
984 # setup invoice, and the amount on the recurring invoice.
985 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
986 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
988 'description' => $qpt->itemdesc,
989 'ext_description' => [],
992 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
994 } # foreach $quotation_pkg
996 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
997 my $this_tax = $tax_item{$taxname};
998 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
999 next if $this_tax->{'amount'} == 0;
1000 push @items, $this_tax;
1016 L<FS::Record>, schema.html from the base documentation.