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 my $disable_total = 0;
265 foreach my $pkg ($self->quotation_pkg) {
267 my $part_pkg = $pkg->part_pkg;
269 my $recur_freq = $part_pkg->freq;
270 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
271 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
273 #this is a shitty hack based on what's in part_pkg/ at the moment
274 # but its good enough for the 99% common case of preventing totals from
275 # displaying for prorate packages
277 if $part_pkg->plan =~ /^prorate/
278 || $part_pkg->plan eq 'agent'
279 || $part_pkg->plan =~ /^torrus/
280 || $part_pkg->option('sync_bill_date');
283 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
286 my $no_recurring = 0;
287 foreach my $freq (keys %subtotals) {
289 next if $subtotals{$freq} == 0;
292 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
294 if ( $freq eq '0' ) {
295 if ( scalar(keys(%subtotals)) == 1 ) {
296 # there are no recurring packages
298 $desc = $self->mt('Charges');
300 $desc = $self->mt('Setup Charges');
303 $desc = $self->mt('Recurring Charges') . ' - ' .
304 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
308 'description' => &$escape($desc),
309 'sort_weight' => $weight,
311 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
315 unless ( $disable_total || $no_recurring ) {
317 $total += $_ for values %subtotals;
319 'description' => 'First payment',
321 'category' => 'Total category', #required but what's it used for?
322 'subtotal' => sprintf('%.2f',$total)
326 return \@sections, [];
329 =item enable_previous
333 sub enable_previous { 0 }
335 =item convert_cust_main
337 If this quotation already belongs to a customer, then returns that customer, as
338 an FS::cust_main object.
340 Otherwise, creates a new customer (FS::cust_main object and record, and
341 associated) based on this quotation's prospect, then orders this quotation's
342 packages as real packages for the customer.
344 If there is an error, returns an error message, otherwise, returns the
345 newly-created FS::cust_main object.
349 sub convert_cust_main {
352 my $cust_main = $self->cust_main;
353 return $cust_main if $cust_main; #already converted, don't again
355 my $oldAutoCommit = $FS::UID::AutoCommit;
356 local $FS::UID::AutoCommit = 0;
359 $cust_main = $self->prospect_main->convert_cust_main;
360 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
361 $dbh->rollback if $oldAutoCommit;
365 $self->prospectnum('');
366 $self->custnum( $cust_main->custnum );
367 my $error = $self->replace || $self->order;
369 $dbh->rollback if $oldAutoCommit;
373 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
379 =item order [ HASHREF ]
381 This method is for use with quotations which are already associated with a customer.
383 Orders this quotation's packages as real packages for the customer.
385 If there is an error, returns an error message, otherwise returns false.
387 If HASHREF is passed, it will be filled with a hash mapping the
388 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
395 my $pkgnum_map = shift || {};
397 tie my %all_cust_pkg, 'Tie::RefHash';
398 foreach my $quotation_pkg ($self->quotation_pkg) {
399 my $cust_pkg = FS::cust_pkg->new;
400 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
402 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
403 $cust_pkg->set( $_, $quotation_pkg->get($_) );
406 # currently only one discount each
407 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
408 if ( $pkg_discount ) {
409 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
412 $all_cust_pkg{$cust_pkg} = []; # no services
415 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
417 foreach my $quotationpkgnum (keys %$pkgnum_map) {
418 # convert the objects to just pkgnums
419 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
420 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
428 One-time charges, like FS::cust_main::charge()
432 #super false laziness w/cust_main::charge
435 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
436 my ( $pkg, $comment, $additional );
437 my ( $setuptax, $taxclass ); #internal taxes
438 my ( $taxproduct, $override ); #vendor (CCH) taxes
440 my $cust_pkg_ref = '';
441 my ( $bill_now, $invoice_terms ) = ( 0, '' );
443 if ( ref( $_[0] ) ) {
444 $amount = $_[0]->{amount};
445 $setup_cost = $_[0]->{setup_cost};
446 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
447 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
448 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
449 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
450 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
451 : '$'. sprintf("%.2f",$amount);
452 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
453 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
454 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
455 $additional = $_[0]->{additional} || [];
456 $taxproduct = $_[0]->{taxproductnum};
457 $override = { '' => $_[0]->{tax_override} };
458 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
459 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
460 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
461 $locationnum = $_[0]->{locationnum};
467 $pkg = @_ ? shift : 'One-time charge';
468 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
470 $taxclass = @_ ? shift : '';
474 local $SIG{HUP} = 'IGNORE';
475 local $SIG{INT} = 'IGNORE';
476 local $SIG{QUIT} = 'IGNORE';
477 local $SIG{TERM} = 'IGNORE';
478 local $SIG{TSTP} = 'IGNORE';
479 local $SIG{PIPE} = 'IGNORE';
481 my $oldAutoCommit = $FS::UID::AutoCommit;
482 local $FS::UID::AutoCommit = 0;
485 my $part_pkg = new FS::part_pkg ( {
487 'comment' => $comment,
491 'classnum' => ( $classnum ? $classnum : '' ),
492 'setuptax' => $setuptax,
493 'taxclass' => $taxclass,
494 'taxproductnum' => $taxproduct,
495 'setup_cost' => $setup_cost,
498 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
499 ( 0 .. @$additional - 1 )
501 'additional_count' => scalar(@$additional),
502 'setup_fee' => $amount,
505 my $error = $part_pkg->insert( options => \%options,
506 tax_overrides => $override,
509 $dbh->rollback if $oldAutoCommit;
513 my $pkgpart = $part_pkg->pkgpart;
516 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
518 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
519 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
520 $error = $type_pkgs->insert;
522 $dbh->rollback if $oldAutoCommit;
527 #except for DIFF, eveything above is idential to cust_main version
528 #but below is our own thing pretty much (adding a quotation package instead
529 # of ordering a customer package, no "bill now")
531 my $quotation_pkg = new FS::quotation_pkg ( {
532 'quotationnum' => $self->quotationnum,
533 'pkgpart' => $pkgpart,
534 'quantity' => $quantity,
535 #'start_date' => $start_date,
536 #'no_auto' => $no_auto,
537 'locationnum'=> $locationnum,
540 $error = $quotation_pkg->insert;
542 $dbh->rollback if $oldAutoCommit;
544 #} elsif ( $cust_pkg_ref ) {
545 # ${$cust_pkg_ref} = $cust_pkg;
548 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
555 Disables this quotation (sets disabled to Y, which hides the quotation on
556 prospects and customers).
558 If there is an error, returns an error message, otherwise returns false.
564 $self->disabled('Y');
570 Enables this quotation.
572 If there is an error, returns an error message, otherwise returns false.
584 Calculates current prices for all items on this quotation, including
585 discounts and taxes, and updates the quotation_pkg records accordingly.
591 my $conf = FS::Conf->new;
593 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
595 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
597 my @return_bill = ([]);
600 ###### BEGIN TRANSACTION ######
604 my $temp_dbh = myconnect();
605 local $FS::UID::dbh = $temp_dbh;
606 local $FS::UID::AutoCommit = 0;
608 my $fake_self = FS::quotation->new({ $self->hash });
610 # if this is a prospect, make them into a customer for now
611 # XXX prospects currently can't have service locations
612 my $cust_or_prospect = $self->cust_or_prospect;
614 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
615 $cust_main = $cust_or_prospect->convert_cust_main;
616 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
617 $fake_self->set('prospectnum', '');
618 $fake_self->set('custnum', $cust_main->custnum);
620 $cust_main = $cust_or_prospect;
624 $error = $fake_self->order(\%pkgnum_of);
625 die "$error (simulating package order)\n" if $error;
627 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
629 # simulate the first bill
632 'pkg_list' => \@new_pkgs,
633 'time' => time, # an option to adjust this?
634 'return_bill' => $return_bill[0],
635 'no_usage_reset' => 1,
637 $error = $cust_main->bill(%bill_opt);
638 die "$error (simulating initial billing)\n" if $error;
640 # pick dates for future bills
642 foreach (@new_pkgs) {
643 my $bill = $_->get('bill');
645 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
649 foreach my $next_bill (keys %next_bill_pkgs) {
650 $bill_opt{'time'} = $next_bill;
651 $bill_opt{'return_bill'} = $return_bill[$i] = [];
652 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
653 $error = $cust_main->bill(%bill_opt);
654 die "$error (simulating recurring billing cycle $i)\n" if $error;
661 ###### END TRANSACTION ######
662 my %quotationpkgnum_of = reverse %pkgnum_of;
665 warn "pkgnums:\n".Dumper(\%pkgnum_of);
666 warn Dumper(\@return_bill);
669 # Careful: none of the foreign keys in here are correct outside the sandbox.
670 # We have a translation table for pkgnums; all others are total lies.
672 my %quotation_pkg; # quotationpkgnum => quotation_pkg
673 foreach my $qp ($self->quotation_pkg) {
674 $quotation_pkg{$qp->quotationpkgnum} = $qp;
675 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
676 $qp->set('freq', '');
677 # flush old tax records
678 foreach ($qp->quotation_pkg_tax) {
680 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
685 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
686 my %quotation_pkg_discount; # quotationpkgnum => quotation_pkg_discount obj
688 for (my $i = 0; $i < scalar(@return_bill); $i++) {
689 my $this_bill = $return_bill[$i]->[0];
691 warn "$me billing cycle $i produced no invoice\n";
697 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
698 my $pkgnum = $cust_bill_pkg->pkgnum;
699 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
701 # taxes/fees; come back to it
702 push @nonpkg_lines, $cust_bill_pkg;
705 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
706 my $qp = $quotation_pkg{$quotationpkgnum};
708 # XXX supplemental packages could do this (they have separate pkgnums)
709 # handle that special case at some point
710 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
714 # then this is the first (setup) invoice
715 $qp->set('start_date', $cust_bill_pkg->sdate);
716 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
717 # pkgpart_override is a possibility
719 # recurring invoice (should be only one of these per package, though
720 # it may have multiple lineitems with the same pkgnum)
721 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
725 if ( $cust_bill_pkg->get('discounts') ) {
726 my $discount = $cust_bill_pkg->get('discounts')->[0];
728 # discount records are generated as (setup, recur).
729 # well, not always, sometimes it's just (recur), but fixing this
730 # is horribly invasive.
731 my $qpd = $quotation_pkg_discount{$quotationpkgnum}
732 ||= qsearchs('quotation_pkg_discount', {
733 'quotationpkgnum' => $quotationpkgnum
736 if (!$qpd) { #can't happen
737 warn "$me simulated bill returned a discount but no discount is in effect.\n";
739 if ($discount and $qpd) {
741 $qpd->set('setup_amount', $discount->amount);
743 $qpd->set('recur_amount', $discount->amount);
747 } # end of discount stuff
752 foreach my $cust_bill_pkg (@nonpkg_lines) {
754 my $itemdesc = $cust_bill_pkg->itemdesc;
756 if ($cust_bill_pkg->feepart) {
757 warn "$me simulated bill included a non-package fee (feepart ".
758 $cust_bill_pkg->feepart.")\n";
761 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
762 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
764 # breadth-first unrolled recursion:
765 # take each tax link and any tax-on-tax descendants, and merge them
766 # into a single quotation_pkg_tax record for each pkgnum/taxname
768 while (my $tax_link = shift @$links) {
769 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
770 or die "$me unable to resolve tax link\n";
771 if ($target->pkgnum) {
772 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
773 # create this if there isn't one yet
774 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
775 FS::quotation_pkg_tax->new({
776 quotationpkgnum => $quotationpkgnum,
777 itemdesc => $itemdesc,
781 if ( $i == 0 ) { # first invoice
782 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
783 } else { # subsequent invoices
784 # this isn't perfectly accurate, but that's why it's an estimate
785 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
786 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
787 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
789 } elsif ($target->feepart) {
790 # do nothing; we already warned for the fee itself
792 # tax on tax: the tax target is another tax item.
793 # since this is an estimate, I'm just going to assign it to the
794 # first of the underlying packages. (RT#5243 is why we can't have
796 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
797 if ($sublinks and $sublinks->[0]) {
798 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
799 push @$links, $tax_link; #try again
801 warn "$me unable to assign tax on tax; ignoring\n";
804 } # while my $tax_link
806 } # foreach my $cust_bill_pkg
808 foreach my $quotation_pkg (values %quotation_pkg) {
809 $error = $quotation_pkg->replace;
810 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
813 foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
814 $error = $quotation_pkg_discount->replace;
815 return "$error (recording estimated discount)"
818 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
819 $error = $quotation_pkg_tax->insert;
820 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
833 =item search_sql_where HASHREF
835 Class method which returns an SQL WHERE fragment to search for parameters
836 specified in HASHREF. Valid parameters are
842 List reference of start date, end date, as UNIX timestamps.
852 List reference of charged limits (exclusive).
856 List reference of charged limits (exclusive).
860 flag, return open invoices only
864 flag, return net invoices only
872 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
876 sub search_sql_where {
877 my($class, $param) = @_;
879 # warn "$me search_sql_where called with params: \n".
880 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
886 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
887 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
891 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
892 # push @search, "cust_main.refnum = $1";
896 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
897 push @search, "quotation.prospectnum = $1";
901 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
902 push @search, "cust_bill.custnum = $1";
906 if ( $param->{_date} ) {
907 my($beginning, $ending) = @{$param->{_date}};
909 push @search, "quotation._date >= $beginning",
910 "quotation._date < $ending";
914 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
915 push @search, "quotation.quotationnum >= $1";
917 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
918 push @search, "quotation.quotationnum <= $1";
922 # if ( $param->{charged} ) {
923 # my @charged = ref($param->{charged})
924 # ? @{ $param->{charged} }
925 # : ($param->{charged});
927 # push @search, map { s/^charged/cust_bill.charged/; $_; }
931 my $owed_sql = FS::cust_bill->owed_sql;
934 push @search, "quotation._date < ". (time-86400*$param->{'days'})
937 #agent virtualization
938 my $curuser = $FS::CurrentUser::CurrentUser;
939 #false laziness w/search/quotation.html
940 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
941 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
944 join(' AND ', @search );
950 Return line item hashes for each package on this quotation.
955 my ($self, %options) = @_;
956 my $escape = $options{'escape_function'};
957 my $locale = $self->cust_or_prospect->locale;
959 my $preref = $options{'preref_callback'};
961 my $section = $options{'section'};
962 my $freq = $section->{'category'};
963 my @pkgs = $self->quotation_pkg;
965 die "_items_pkg called without section->{'category'}"
966 unless defined $freq;
968 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
969 # like we should have done in the first place
971 foreach my $quotation_pkg (@pkgs) {
972 my $part_pkg = $quotation_pkg->part_pkg;
975 'pkgnum' => $quotation_pkg->quotationpkgnum,
976 'description' => $quotation_pkg->desc($locale),
977 'ext_description' => [],
978 'quantity' => $quotation_pkg->quantity,
982 $setuprecur = 'setup';
983 if ($part_pkg->freq ne '0') {
984 # indicate that it's a setup fee on a recur package (cust_bill does
986 $this_item->{'description'} .= ' Setup';
989 # recur for this frequency
990 next if $freq ne $part_pkg->freq;
991 $setuprecur = 'recur';
994 $this_item->{'unit_amount'} = sprintf('%.2f',
995 $quotation_pkg->get('unit'.$setuprecur));
996 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
997 * $quotation_pkg->quantity);
998 next if $this_item->{'amount'} == 0;
1001 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1004 push @items, $this_item;
1005 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1007 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1008 push @items, $discount;
1011 # each quotation_pkg_tax has two amounts: the amount charged on the
1012 # setup invoice, and the amount on the recurring invoice.
1013 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1014 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1016 'description' => $qpt->itemdesc,
1017 'ext_description' => [],
1020 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1022 } # foreach $quotation_pkg
1024 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1025 my $this_tax = $tax_item{$taxname};
1026 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1027 next if $this_tax->{'amount'} == 0;
1028 push @items, $this_tax;
1044 L<FS::Record>, schema.html from the base documentation.