2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
8 use FS::Maketext qw( emt );
9 use FS::Record qw( qsearch qsearchs );
13 use FS::prospect_main;
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
20 FS::quotation - Object methods for quotation records
26 $record = new FS::quotation \%hash;
27 $record = new FS::quotation { 'column' => 'value' };
29 $error = $record->insert;
31 $error = $new_record->replace($old_record);
33 $error = $record->delete;
35 $error = $record->check;
39 An FS::quotation object represents a quotation. FS::quotation inherits from
40 FS::Record. The following fields are currently supported:
70 projected date when the quotation will be closed
74 projected confidence (expressed as integer) that quotation will close
84 Creates a new quotation. To add the quotation to the database, see L<"insert">.
86 Note that this stores the hash reference, not a distinct copy of the hash it
87 points to. You can ask the object for a copy with the I<hash> method.
91 sub table { 'quotation'; }
92 sub notice_name { 'Quotation'; }
93 sub template_conf { 'quotation_'; }
97 Adds this record to the database. If there is an error, returns the error,
98 otherwise returns false.
102 Delete this record from the database.
104 =item replace OLD_RECORD
106 Replaces the OLD_RECORD with this one in the database. If there is an error,
107 returns the error, otherwise returns false.
111 Checks all fields to make sure this is a valid quotation. If there is
112 an error, returns the error, otherwise returns false. Called by the insert
121 $self->ut_numbern('quotationnum')
122 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
123 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
124 || $self->ut_numbern('_date')
125 || $self->ut_enum('disabled', [ '', 'Y' ])
126 || $self->ut_numbern('usernum')
127 || $self->ut_numbern('close_date')
128 || $self->ut_numbern('confidence')
130 return $error if $error;
132 $self->_date(time) unless $self->_date;
134 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
136 return 'confidence percentage must be an integer between 1 and 100'
137 if length($self->confidence)
138 && ( ($self->confidence < 1) || ($self->confidence > 100) );
140 return 'prospectnum or custnum must be specified'
141 if ! $self->prospectnum
153 qsearchs('prospect_main', { 'prospectnum' => $self->prospectnum } );
162 qsearchs('cust_main', { 'custnum' => $self->custnum } );
169 sub cust_bill_pkg { #actually quotation_pkg objects
171 qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
180 $self->_total('setup');
183 =item total_recur [ FREQ ]
189 #=item total_recur [ FREQ ]
190 #my $freq = @_ ? shift : '';
191 $self->_total('recur');
195 my( $self, $method ) = @_;
198 $total += $_->$method() for $self->cust_bill_pkg;
199 sprintf('%.2f', $total);
205 my $opt = shift || {};
206 if ($opt and !ref($opt)) {
207 die ref($self). '->email called with positional parameters';
210 my $conf = $self->conf;
212 my $from = delete $opt->{from};
214 # this is where we set the From: address
215 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
216 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
217 $self->SUPER::email( {
228 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
231 #my $cust_main = $self->cust_main;
232 #my $name = $cust_main->name;
233 #my $name_short = $cust_main->name_short;
234 #my $invoice_number = $self->invnum;
235 #my $invoice_date = $self->_date_pretty;
242 'Quotation-'. $self->quotationnum. '.pdf';
245 =item cust_or_prosect
249 sub cust_or_prospect {
251 $self->custnum ? $self->cust_main : $self->prospect_main;
254 =item cust_or_prospect_label_link P
256 HTML links to either the customer or prospect.
258 Returns a list consisting of two elements. The first is a text label for the
259 link, and the second is the URL.
263 sub cust_or_prospect_label_link {
264 my( $self, $p ) = @_;
266 if ( my $custnum = $self->custnum ) {
267 my $display_custnum = $self->cust_main->display_custnum;
268 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
270 : ';show=quotations';
272 emt("View this customer (#[_1])",$display_custnum) =>
273 "${p}view/cust_main.cgi?custnum=$custnum$target"
275 } elsif ( my $prospectnum = $self->prospectnum ) {
277 emt("View this prospect (#[_1])",$prospectnum) =>
278 "${p}view/prospect_main.html?$prospectnum"
291 shift->cust_bill_pkg;
296 $self->quotationnum =~ /^(\d+)$/ or return ();
300 # show taxes in here also; the setup/recurring breakdown is different
301 # from what Template_Mixin expects
302 my @setup_tax = qsearch({
303 select => 'itemdesc, SUM(setup_amount) as setup_amount',
304 table => 'quotation_pkg_tax',
305 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ',
306 extra_sql => ' WHERE quotationnum = '.$1,
307 order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
308 ' ORDER BY itemdesc',
310 # recurs need to be grouped by frequency, and to have a pkgpart
311 my @recur_tax = qsearch({
312 select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
313 table => 'quotation_pkg_tax',
314 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'.
315 ' JOIN part_pkg USING (pkgpart)',
316 extra_sql => ' WHERE quotationnum = '.$1,
317 order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
318 ' ORDER BY freq, itemdesc',
321 my $total_setup = $self->total_setup;
322 my $total_recur = $self->total_recur;
323 my $setup_show = $total_setup > 0 ? 1 : 0;
324 my $recur_show = $total_recur > 0 ? 1 : 0;
325 unless ($setup_show && $recur_show) {
326 foreach my $quotation_pkg ($self->quotation_pkg) {
327 $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
328 $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
329 last if $setup_show && $recur_show;
333 foreach my $pkg_tax (@setup_tax) {
334 if ($pkg_tax->setup_amount > 0) {
335 $total_setup += $pkg_tax->setup_amount;
337 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
338 'total_amount' => $pkg_tax->setup_amount,
345 'total_item' => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
346 'total_amount' => sprintf('%.2f',$total_setup),
347 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
351 #could/should add up the different recurring frequencies on lines of their own
352 # but this will cover the 95% cases for now
353 # label these with the frequency
354 foreach my $pkg_tax (@recur_tax) {
355 if ($pkg_tax->recur_amount > 0) {
356 $total_recur += $pkg_tax->recur_amount;
357 # an arbitrary part_pkg, but with the right frequency
359 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
361 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
362 'total_amount' => $pkg_tax->recur_amount,
369 'total_item' => $self->mt('Total Recurring'),
370 'total_amount' => sprintf('%.2f',$total_recur),
374 my $prorate_total = 0;
375 foreach my $quotation_pkg ($self->quotation_pkg) {
376 my $part_pkg = $quotation_pkg->part_pkg;
377 if ( $part_pkg->plan =~ /^(prorate|torrus|agent$)/
378 || $part_pkg->option('recur_method') eq 'prorate'
379 || ( $part_pkg->option('sync_bill_date')
381 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
389 if ( $prorate_total ) {
391 'total_item' => $self->mt('First payment (depending on day of month)'),
392 'total_amount' => [ sprintf('%.2f', $total_setup),
393 sprintf('%.2f', $total_setup + $total_recur)
399 'total_item' => $self->mt('First payment'),
400 'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
411 =item enable_previous
415 sub enable_previous { 0 }
417 =item convert_cust_main [ PARAMS ]
419 If this quotation already belongs to a customer, then returns that customer, as
420 an FS::cust_main object.
422 Otherwise, creates a new customer (FS::cust_main object and record, and
423 associated) based on this quotation's prospect, then orders this quotation's
424 packages as real packages for the customer.
426 If there is an error, returns an error message, otherwise, returns the
427 newly-created FS::cust_main object.
429 Accepts the same params as L</order>.
433 sub convert_cust_main {
435 my $params = shift || {};
437 my $cust_main = $self->cust_main;
438 return $cust_main if $cust_main; #already converted, don't again
440 my $oldAutoCommit = $FS::UID::AutoCommit;
441 local $FS::UID::AutoCommit = 0;
444 $cust_main = $self->prospect_main->convert_cust_main;
445 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
446 $dbh->rollback if $oldAutoCommit;
450 $self->prospectnum('');
451 $self->custnum( $cust_main->custnum );
452 my $error = $self->replace || $self->order(undef,$params);
454 $dbh->rollback if $oldAutoCommit;
458 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
464 =item order [ HASHREF ] [ PARAMS ]
466 This method is for use with quotations which are already associated with a customer.
468 Orders this quotation's packages as real packages for the customer.
470 If there is an error, returns an error message, otherwise returns false.
472 If HASHREF is passed, it will be filled with a hash mapping the
473 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
476 If PARAMS hashref is passed, the following params are accepted:
478 onhold - if true, suspends newly ordered packages
484 my $pkgnum_map = shift || {};
485 my $params = shift || {};
486 my $details_map = {};
488 tie my %all_cust_pkg, 'Tie::RefHash';
489 foreach my $quotation_pkg ($self->quotation_pkg) {
490 my $cust_pkg = FS::cust_pkg->new;
491 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
493 # details will be copied below, after package is ordered
494 $details_map->{ $quotation_pkg->quotationpkgnum } = [
495 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
498 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
499 $cust_pkg->set( $_, $quotation_pkg->get($_) );
502 # currently only one discount each
503 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
504 if ( $pkg_discount ) {
505 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
508 $all_cust_pkg{$cust_pkg} = []; # no services
511 local $SIG{HUP} = 'IGNORE';
512 local $SIG{INT} = 'IGNORE';
513 local $SIG{QUIT} = 'IGNORE';
514 local $SIG{TERM} = 'IGNORE';
515 local $SIG{TSTP} = 'IGNORE';
516 local $SIG{PIPE} = 'IGNORE';
518 my $oldAutoCommit = $FS::UID::AutoCommit;
519 local $FS::UID::AutoCommit = 0;
522 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
525 # copy details (copy_on_order filtering handled above)
526 foreach my $quotationpkgnum (keys %$details_map) {
527 next unless @{$details_map->{$quotationpkgnum}};
528 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
530 @{$details_map->{$quotationpkgnum}}
536 if ($$params{'onhold'}) {
537 foreach my $quotationpkgnum (keys %$pkgnum_map) {
539 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
544 $dbh->rollback if $oldAutoCommit;
548 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
550 foreach my $quotationpkgnum (keys %$pkgnum_map) {
551 # convert the objects to just pkgnums
552 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
553 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
566 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
571 One-time charges, like FS::cust_main::charge()
575 #super false laziness w/cust_main::charge
578 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
579 my ( $pkg, $comment, $additional );
580 my ( $setuptax, $taxclass ); #internal taxes
581 my ( $taxproduct, $override ); #vendor (CCH) taxes
583 my $cust_pkg_ref = '';
584 my ( $bill_now, $invoice_terms ) = ( 0, '' );
586 if ( ref( $_[0] ) ) {
587 $amount = $_[0]->{amount};
588 $setup_cost = $_[0]->{setup_cost};
589 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
590 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
591 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
592 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
593 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
594 : '$'. sprintf("%.2f",$amount);
595 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
596 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
597 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
598 $additional = $_[0]->{additional} || [];
599 $taxproduct = $_[0]->{taxproductnum};
600 $override = { '' => $_[0]->{tax_override} };
601 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
602 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
603 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
604 $locationnum = $_[0]->{locationnum};
610 $pkg = @_ ? shift : 'One-time charge';
611 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
613 $taxclass = @_ ? shift : '';
617 local $SIG{HUP} = 'IGNORE';
618 local $SIG{INT} = 'IGNORE';
619 local $SIG{QUIT} = 'IGNORE';
620 local $SIG{TERM} = 'IGNORE';
621 local $SIG{TSTP} = 'IGNORE';
622 local $SIG{PIPE} = 'IGNORE';
624 my $oldAutoCommit = $FS::UID::AutoCommit;
625 local $FS::UID::AutoCommit = 0;
628 my $part_pkg = new FS::part_pkg ( {
630 'comment' => $comment,
634 'classnum' => ( $classnum ? $classnum : '' ),
635 'setuptax' => $setuptax,
636 'taxclass' => $taxclass,
637 'taxproductnum' => $taxproduct,
638 'setup_cost' => $setup_cost,
641 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
642 ( 0 .. @$additional - 1 )
644 'additional_count' => scalar(@$additional),
645 'setup_fee' => $amount,
648 my $error = $part_pkg->insert( options => \%options,
649 tax_overrides => $override,
652 $dbh->rollback if $oldAutoCommit;
656 my $pkgpart = $part_pkg->pkgpart;
659 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
661 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
662 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
663 $error = $type_pkgs->insert;
665 $dbh->rollback if $oldAutoCommit;
670 #except for DIFF, eveything above is idential to cust_main version
671 #but below is our own thing pretty much (adding a quotation package instead
672 # of ordering a customer package, no "bill now")
674 my $quotation_pkg = new FS::quotation_pkg ( {
675 'quotationnum' => $self->quotationnum,
676 'pkgpart' => $pkgpart,
677 'quantity' => $quantity,
678 #'start_date' => $start_date,
679 #'no_auto' => $no_auto,
680 'locationnum'=> $locationnum,
683 $error = $quotation_pkg->insert;
685 $dbh->rollback if $oldAutoCommit;
687 #} elsif ( $cust_pkg_ref ) {
688 # ${$cust_pkg_ref} = $cust_pkg;
691 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
698 Disables this quotation (sets disabled to Y, which hides the quotation on
699 prospects and customers).
701 If there is an error, returns an error message, otherwise returns false.
707 $self->disabled('Y');
713 Enables this quotation.
715 If there is an error, returns an error message, otherwise returns false.
727 Calculates current prices for all items on this quotation, including
728 discounts and taxes, and updates the quotation_pkg records accordingly.
734 my $conf = FS::Conf->new;
737 my $oldAutoCommit = $FS::UID::AutoCommit;
738 local $FS::UID::AutoCommit = 0;
740 # bring individual items up to date (set setup/recur and discounts)
741 my @quotation_pkg = $self->quotation_pkg;
742 foreach my $pkg (@quotation_pkg) {
743 my $error = $pkg->estimate;
745 $dbh->rollback if $oldAutoCommit;
746 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
749 # delete old tax records
750 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
751 $error = $quotation_pkg_tax->delete;
753 $dbh->rollback if $oldAutoCommit;
754 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
759 # annoyingly duplicates handle_taxes--fix this in 4.x
760 if ( $conf->exists('enable_taxproducts') ) {
761 warn "can't calculate external taxes for quotations yet\n";
766 my %taxnum_exemptions; # for monthly exemptions; as yet unused
768 foreach my $pkg (@quotation_pkg) {
769 my $location = $pkg->cust_location;
771 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
772 my @loc_keys = qw( district city county state country);
773 my %taxhash = map { $_ => $location->$_ } @loc_keys;
774 $taxhash{'taxclass'} = $part_item->taxclass;
776 my %taxhash_elim = %taxhash;
777 my @elim = qw( district city county state );
779 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
780 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
781 #then try a match without taxclass
782 my %no_taxclass = %taxhash_elim;
783 $no_taxclass{ 'taxclass' } = '';
784 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
787 $taxhash_elim{ shift(@elim) } = '';
788 } while ( !scalar(@taxes) && scalar(@elim) );
790 foreach my $tax_def (@taxes) {
791 my $taxnum = $tax_def->taxnum;
792 $taxnum_exemptions{$taxnum} ||= [];
794 # XXX do some kind of equivalent to set_exemptions here
795 # but for now just declare that there are no exemptions,
796 # and then hack the taxable amounts if the package def
797 # excludes setup/recur
798 $pkg->set('cust_tax_exempt_pkg', []);
800 if ( $part_item->setuptax or $tax_def->setuptax ) {
801 $pkg->set('unitsetup', 0);
803 if ( $part_item->recurtax or $tax_def->recurtax ) {
804 $pkg->set('unitrecur', 0);
808 foreach my $pass (qw(first recur)) {
809 if ($pass eq 'recur') {
810 $pkg->set('unitsetup', 0);
813 my $taxline = $tax_def->taxline(
815 exemptions => $taxnum_exemptions{$taxnum}
817 if ($taxline and !ref($taxline)) {
818 $dbh->rollback if $oldAutoCommit;
819 die "error calculating '".$tax_def->taxname .
820 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
822 $taxline{$pass} = $taxline;
825 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
826 quotationpkgnum => $pkg->quotationpkgnum,
827 itemdesc => ($tax_def->taxname || 'Tax'),
829 taxtype => ref($tax_def),
831 my $setup_amount = 0;
832 my $recur_amount = 0;
833 if ($taxline{first}) {
834 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
836 if ($taxline{recur}) {
837 $recur_amount = $taxline{recur}->setup;
838 $setup_amount -= $recur_amount; # to get the actual setup amount
840 if ( $recur_amount > 0 or $setup_amount > 0 ) {
841 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
842 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
844 my $error = $quotation_pkg_tax->insert;
846 $dbh->rollback if $oldAutoCommit;
847 die "error recording '".$tax_def->taxname .
848 "' for pkgpart '".$pkg->pkgpart."': $error\n";
850 } # else there are no non-zero taxes; continue
854 $dbh->commit if $oldAutoCommit;
865 =item search_sql_where HASHREF
867 Class method which returns an SQL WHERE fragment to search for parameters
868 specified in HASHREF. Valid parameters are
874 List reference of start date, end date, as UNIX timestamps.
884 List reference of charged limits (exclusive).
888 List reference of charged limits (exclusive).
892 flag, return open invoices only
896 flag, return net invoices only
904 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
908 sub search_sql_where {
909 my($class, $param) = @_;
911 # warn "$me search_sql_where called with params: \n".
912 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
918 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
919 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
923 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
924 # push @search, "cust_main.refnum = $1";
928 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
929 push @search, "quotation.prospectnum = $1";
933 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
934 push @search, "cust_bill.custnum = $1";
938 if ( $param->{_date} ) {
939 my($beginning, $ending) = @{$param->{_date}};
941 push @search, "quotation._date >= $beginning",
942 "quotation._date < $ending";
946 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
947 push @search, "quotation.quotationnum >= $1";
949 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
950 push @search, "quotation.quotationnum <= $1";
954 # if ( $param->{charged} ) {
955 # my @charged = ref($param->{charged})
956 # ? @{ $param->{charged} }
957 # : ($param->{charged});
959 # push @search, map { s/^charged/cust_bill.charged/; $_; }
963 my $owed_sql = FS::cust_bill->owed_sql;
966 push @search, "quotation._date < ". (time-86400*$param->{'days'})
969 #agent virtualization
970 my $curuser = $FS::CurrentUser::CurrentUser;
971 #false laziness w/search/quotation.html
972 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
973 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
976 join(' AND ', @search );
982 Return line item hashes for each package on this quotation. Differs from the
983 base L<FS::Template_Mixin> version in that it recalculates each quoted package
984 first, and doesn't implement the "condensed" option.
989 my ($self, %options) = @_;
991 # run it through the Template_Mixin engine
992 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
1001 L<FS::Record>, schema.html from the base documentation.