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|torrus|agent$)/
278 || $part_pkg->option('recur_method') eq 'prorate'
279 || ( $part_pkg->option('sync_bill_date')
281 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
285 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
288 my $no_recurring = 0;
289 foreach my $freq (keys %subtotals) {
291 next if $subtotals{$freq} == 0;
294 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
296 if ( $freq eq '0' ) {
297 if ( scalar(keys(%subtotals)) == 1 ) {
298 # there are no recurring packages
300 $desc = $self->mt('Charges');
302 $desc = $self->mt('Setup Charges');
305 $desc = $self->mt('Recurring Charges') . ' - ' .
306 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
310 'description' => &$escape($desc),
311 'sort_weight' => $weight,
313 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
317 unless ( $disable_total || $no_recurring ) {
319 $total += $_ for values %subtotals;
321 'description' => 'First payment',
323 'category' => 'Total category', #required but what's it used for?
324 'subtotal' => sprintf('%.2f',$total)
328 return \@sections, [];
331 =item enable_previous
335 sub enable_previous { 0 }
337 =item convert_cust_main
339 If this quotation already belongs to a customer, then returns that customer, as
340 an FS::cust_main object.
342 Otherwise, creates a new customer (FS::cust_main object and record, and
343 associated) based on this quotation's prospect, then orders this quotation's
344 packages as real packages for the customer.
346 If there is an error, returns an error message, otherwise, returns the
347 newly-created FS::cust_main object.
351 sub convert_cust_main {
354 my $cust_main = $self->cust_main;
355 return $cust_main if $cust_main; #already converted, don't again
357 my $oldAutoCommit = $FS::UID::AutoCommit;
358 local $FS::UID::AutoCommit = 0;
361 $cust_main = $self->prospect_main->convert_cust_main;
362 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
363 $dbh->rollback if $oldAutoCommit;
367 $self->prospectnum('');
368 $self->custnum( $cust_main->custnum );
369 my $error = $self->replace || $self->order;
371 $dbh->rollback if $oldAutoCommit;
375 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
381 =item order [ HASHREF ]
383 This method is for use with quotations which are already associated with a customer.
385 Orders this quotation's packages as real packages for the customer.
387 If there is an error, returns an error message, otherwise returns false.
389 If HASHREF is passed, it will be filled with a hash mapping the
390 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
397 my $pkgnum_map = shift || {};
399 tie my %all_cust_pkg, 'Tie::RefHash';
400 foreach my $quotation_pkg ($self->quotation_pkg) {
401 my $cust_pkg = FS::cust_pkg->new;
402 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
404 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
405 $cust_pkg->set( $_, $quotation_pkg->get($_) );
408 # currently only one discount each
409 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
410 if ( $pkg_discount ) {
411 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
414 $all_cust_pkg{$cust_pkg} = []; # no services
417 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
419 foreach my $quotationpkgnum (keys %$pkgnum_map) {
420 # convert the objects to just pkgnums
421 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
422 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
430 One-time charges, like FS::cust_main::charge()
434 #super false laziness w/cust_main::charge
437 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
438 my ( $pkg, $comment, $additional );
439 my ( $setuptax, $taxclass ); #internal taxes
440 my ( $taxproduct, $override ); #vendor (CCH) taxes
442 my $cust_pkg_ref = '';
443 my ( $bill_now, $invoice_terms ) = ( 0, '' );
445 if ( ref( $_[0] ) ) {
446 $amount = $_[0]->{amount};
447 $setup_cost = $_[0]->{setup_cost};
448 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
449 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
450 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
451 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
452 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
453 : '$'. sprintf("%.2f",$amount);
454 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
455 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
456 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
457 $additional = $_[0]->{additional} || [];
458 $taxproduct = $_[0]->{taxproductnum};
459 $override = { '' => $_[0]->{tax_override} };
460 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
461 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
462 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
463 $locationnum = $_[0]->{locationnum};
469 $pkg = @_ ? shift : 'One-time charge';
470 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
472 $taxclass = @_ ? shift : '';
476 local $SIG{HUP} = 'IGNORE';
477 local $SIG{INT} = 'IGNORE';
478 local $SIG{QUIT} = 'IGNORE';
479 local $SIG{TERM} = 'IGNORE';
480 local $SIG{TSTP} = 'IGNORE';
481 local $SIG{PIPE} = 'IGNORE';
483 my $oldAutoCommit = $FS::UID::AutoCommit;
484 local $FS::UID::AutoCommit = 0;
487 my $part_pkg = new FS::part_pkg ( {
489 'comment' => $comment,
493 'classnum' => ( $classnum ? $classnum : '' ),
494 'setuptax' => $setuptax,
495 'taxclass' => $taxclass,
496 'taxproductnum' => $taxproduct,
497 'setup_cost' => $setup_cost,
500 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
501 ( 0 .. @$additional - 1 )
503 'additional_count' => scalar(@$additional),
504 'setup_fee' => $amount,
507 my $error = $part_pkg->insert( options => \%options,
508 tax_overrides => $override,
511 $dbh->rollback if $oldAutoCommit;
515 my $pkgpart = $part_pkg->pkgpart;
518 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
520 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
521 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
522 $error = $type_pkgs->insert;
524 $dbh->rollback if $oldAutoCommit;
529 #except for DIFF, eveything above is idential to cust_main version
530 #but below is our own thing pretty much (adding a quotation package instead
531 # of ordering a customer package, no "bill now")
533 my $quotation_pkg = new FS::quotation_pkg ( {
534 'quotationnum' => $self->quotationnum,
535 'pkgpart' => $pkgpart,
536 'quantity' => $quantity,
537 #'start_date' => $start_date,
538 #'no_auto' => $no_auto,
539 'locationnum'=> $locationnum,
542 $error = $quotation_pkg->insert;
544 $dbh->rollback if $oldAutoCommit;
546 #} elsif ( $cust_pkg_ref ) {
547 # ${$cust_pkg_ref} = $cust_pkg;
550 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
557 Disables this quotation (sets disabled to Y, which hides the quotation on
558 prospects and customers).
560 If there is an error, returns an error message, otherwise returns false.
566 $self->disabled('Y');
572 Enables this quotation.
574 If there is an error, returns an error message, otherwise returns false.
586 Calculates current prices for all items on this quotation, including
587 discounts and taxes, and updates the quotation_pkg records accordingly.
593 my $conf = FS::Conf->new;
595 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
597 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
599 my @return_bill = ([]);
602 ###### BEGIN TRANSACTION ######
606 my $temp_dbh = myconnect();
607 local $FS::UID::dbh = $temp_dbh;
608 local $FS::UID::AutoCommit = 0;
610 my $fake_self = FS::quotation->new({ $self->hash });
612 # if this is a prospect, make them into a customer for now
613 # XXX prospects currently can't have service locations
614 my $cust_or_prospect = $self->cust_or_prospect;
616 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
617 $cust_main = $cust_or_prospect->convert_cust_main;
618 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
619 $fake_self->set('prospectnum', '');
620 $fake_self->set('custnum', $cust_main->custnum);
622 $cust_main = $cust_or_prospect;
626 $error = $fake_self->order(\%pkgnum_of);
627 die "$error (simulating package order)\n" if $error;
629 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
631 # simulate the first bill
634 'pkg_list' => \@new_pkgs,
635 'time' => time, # an option to adjust this?
636 'return_bill' => $return_bill[0],
637 'no_usage_reset' => 1,
639 $error = $cust_main->bill(%bill_opt);
640 die "$error (simulating initial billing)\n" if $error;
642 # pick dates for future bills
644 foreach (@new_pkgs) {
645 my $bill = $_->get('bill');
647 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
651 foreach my $next_bill (keys %next_bill_pkgs) {
652 $bill_opt{'time'} = $next_bill;
653 $bill_opt{'return_bill'} = $return_bill[$i] = [];
654 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
655 $error = $cust_main->bill(%bill_opt);
656 die "$error (simulating recurring billing cycle $i)\n" if $error;
663 ###### END TRANSACTION ######
664 my %quotationpkgnum_of = reverse %pkgnum_of;
667 warn "pkgnums:\n".Dumper(\%pkgnum_of);
668 warn Dumper(\@return_bill);
671 # Careful: none of the foreign keys in here are correct outside the sandbox.
672 # We have a translation table for pkgnums; all others are total lies.
674 my %quotation_pkg; # quotationpkgnum => quotation_pkg
675 foreach my $qp ($self->quotation_pkg) {
676 $quotation_pkg{$qp->quotationpkgnum} = $qp;
677 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
678 $qp->set('freq', '');
679 # flush old tax records
680 foreach ($qp->quotation_pkg_tax) {
682 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
687 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
688 my %quotation_pkg_discount; # quotationpkgnum => quotation_pkg_discount obj
690 for (my $i = 0; $i < scalar(@return_bill); $i++) {
691 my $this_bill = $return_bill[$i]->[0];
693 warn "$me billing cycle $i produced no invoice\n";
699 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
700 my $pkgnum = $cust_bill_pkg->pkgnum;
701 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
703 # taxes/fees; come back to it
704 push @nonpkg_lines, $cust_bill_pkg;
707 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
708 my $qp = $quotation_pkg{$quotationpkgnum};
710 # XXX supplemental packages could do this (they have separate pkgnums)
711 # handle that special case at some point
712 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
716 # then this is the first (setup) invoice
717 $qp->set('start_date', $cust_bill_pkg->sdate);
718 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
719 # pkgpart_override is a possibility
721 # recurring invoice (should be only one of these per package, though
722 # it may have multiple lineitems with the same pkgnum)
723 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
727 if ( $cust_bill_pkg->get('discounts') ) {
728 my $discount = $cust_bill_pkg->get('discounts')->[0];
730 # discount records are generated as (setup, recur).
731 # well, not always, sometimes it's just (recur), but fixing this
732 # is horribly invasive.
733 my $qpd = $quotation_pkg_discount{$quotationpkgnum}
734 ||= qsearchs('quotation_pkg_discount', {
735 'quotationpkgnum' => $quotationpkgnum
738 if (!$qpd) { #can't happen
739 warn "$me simulated bill returned a discount but no discount is in effect.\n";
741 if ($discount and $qpd) {
743 $qpd->set('setup_amount', $discount->amount);
745 $qpd->set('recur_amount', $discount->amount);
749 } # end of discount stuff
754 foreach my $cust_bill_pkg (@nonpkg_lines) {
756 my $itemdesc = $cust_bill_pkg->itemdesc;
758 if ($cust_bill_pkg->feepart) {
759 warn "$me simulated bill included a non-package fee (feepart ".
760 $cust_bill_pkg->feepart.")\n";
763 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
764 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
766 # breadth-first unrolled recursion:
767 # take each tax link and any tax-on-tax descendants, and merge them
768 # into a single quotation_pkg_tax record for each pkgnum/taxname
770 while (my $tax_link = shift @$links) {
771 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
772 or die "$me unable to resolve tax link\n";
773 if ($target->pkgnum) {
774 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
775 # create this if there isn't one yet
776 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
777 FS::quotation_pkg_tax->new({
778 quotationpkgnum => $quotationpkgnum,
779 itemdesc => $itemdesc,
783 if ( $i == 0 ) { # first invoice
784 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
785 } else { # subsequent invoices
786 # this isn't perfectly accurate, but that's why it's an estimate
787 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
788 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
789 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
791 } elsif ($target->feepart) {
792 # do nothing; we already warned for the fee itself
794 # tax on tax: the tax target is another tax item.
795 # since this is an estimate, I'm just going to assign it to the
796 # first of the underlying packages. (RT#5243 is why we can't have
798 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
799 if ($sublinks and $sublinks->[0]) {
800 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
801 push @$links, $tax_link; #try again
803 warn "$me unable to assign tax on tax; ignoring\n";
806 } # while my $tax_link
808 } # foreach my $cust_bill_pkg
810 foreach my $quotation_pkg (values %quotation_pkg) {
811 $error = $quotation_pkg->replace;
812 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
815 foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
816 $error = $quotation_pkg_discount->replace;
817 return "$error (recording estimated discount)"
820 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
821 $error = $quotation_pkg_tax->insert;
822 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
835 =item search_sql_where HASHREF
837 Class method which returns an SQL WHERE fragment to search for parameters
838 specified in HASHREF. Valid parameters are
844 List reference of start date, end date, as UNIX timestamps.
854 List reference of charged limits (exclusive).
858 List reference of charged limits (exclusive).
862 flag, return open invoices only
866 flag, return net invoices only
874 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
878 sub search_sql_where {
879 my($class, $param) = @_;
881 # warn "$me search_sql_where called with params: \n".
882 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
888 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
889 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
893 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
894 # push @search, "cust_main.refnum = $1";
898 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
899 push @search, "quotation.prospectnum = $1";
903 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
904 push @search, "cust_bill.custnum = $1";
908 if ( $param->{_date} ) {
909 my($beginning, $ending) = @{$param->{_date}};
911 push @search, "quotation._date >= $beginning",
912 "quotation._date < $ending";
916 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
917 push @search, "quotation.quotationnum >= $1";
919 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
920 push @search, "quotation.quotationnum <= $1";
924 # if ( $param->{charged} ) {
925 # my @charged = ref($param->{charged})
926 # ? @{ $param->{charged} }
927 # : ($param->{charged});
929 # push @search, map { s/^charged/cust_bill.charged/; $_; }
933 my $owed_sql = FS::cust_bill->owed_sql;
936 push @search, "quotation._date < ". (time-86400*$param->{'days'})
939 #agent virtualization
940 my $curuser = $FS::CurrentUser::CurrentUser;
941 #false laziness w/search/quotation.html
942 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
943 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
946 join(' AND ', @search );
952 Return line item hashes for each package on this quotation.
957 my ($self, %options) = @_;
958 my $escape = $options{'escape_function'};
959 my $locale = $self->cust_or_prospect->locale;
961 my $preref = $options{'preref_callback'};
963 my $section = $options{'section'};
964 my $freq = $section->{'category'};
965 my @pkgs = $self->quotation_pkg;
967 die "_items_pkg called without section->{'category'}"
968 unless defined $freq;
970 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
971 # like we should have done in the first place
973 foreach my $quotation_pkg (@pkgs) {
974 my $part_pkg = $quotation_pkg->part_pkg;
977 'pkgnum' => $quotation_pkg->quotationpkgnum,
978 'description' => $quotation_pkg->desc($locale),
979 'ext_description' => [],
980 'quantity' => $quotation_pkg->quantity,
984 $setuprecur = 'setup';
985 if ($part_pkg->freq ne '0') {
986 # indicate that it's a setup fee on a recur package (cust_bill does
988 $this_item->{'description'} .= ' Setup';
991 # recur for this frequency
992 next if $freq ne $part_pkg->freq;
993 $setuprecur = 'recur';
996 $this_item->{'unit_amount'} = sprintf('%.2f',
997 $quotation_pkg->get('unit'.$setuprecur));
998 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
999 * $quotation_pkg->quantity);
1000 next if $this_item->{'amount'} == 0;
1003 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1006 push @items, $this_item;
1007 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1009 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1010 push @items, $discount;
1013 # each quotation_pkg_tax has two amounts: the amount charged on the
1014 # setup invoice, and the amount on the recurring invoice.
1015 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1016 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1018 'description' => $qpt->itemdesc,
1019 'ext_description' => [],
1022 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1024 } # foreach $quotation_pkg
1026 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1027 my $this_tax = $tax_item{$taxname};
1028 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1029 next if $this_tax->{'amount'} == 0;
1030 push @items, $this_tax;
1046 L<FS::Record>, schema.html from the base documentation.