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;
240 =item cust_or_prosect
244 sub cust_or_prospect {
246 $self->custnum ? $self->cust_main : $self->prospect_main;
249 =item cust_or_prospect_label_link P
251 HTML links to either the customer or prospect.
253 Returns a list consisting of two elements. The first is a text label for the
254 link, and the second is the URL.
258 sub cust_or_prospect_label_link {
259 my( $self, $p ) = @_;
261 if ( my $custnum = $self->custnum ) {
262 my $display_custnum = $self->cust_main->display_custnum;
263 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
265 : ';show=quotations';
267 emt("View this customer (#[_1])",$display_custnum) =>
268 "${p}view/cust_main.cgi?custnum=$custnum$target"
270 } elsif ( my $prospectnum = $self->prospectnum ) {
272 emt("View this prospect (#[_1])",$prospectnum) =>
273 "${p}view/prospect_main.html?$prospectnum"
286 shift->cust_bill_pkg;
291 $self->quotationnum =~ /^(\d+)$/ or return ();
295 # show taxes in here also; the setup/recurring breakdown is different
296 # from what Template_Mixin expects
297 my @setup_tax = qsearch({
298 select => 'itemdesc, SUM(setup_amount) as setup_amount',
299 table => 'quotation_pkg_tax',
300 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ',
301 extra_sql => ' WHERE quotationnum = '.$1,
302 order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
303 ' ORDER BY itemdesc',
305 # recurs need to be grouped by frequency, and to have a pkgpart
306 my @recur_tax = qsearch({
307 select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
308 table => 'quotation_pkg_tax',
309 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'.
310 ' JOIN part_pkg USING (pkgpart)',
311 extra_sql => ' WHERE quotationnum = '.$1,
312 order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
313 ' ORDER BY freq, itemdesc',
316 my $total_setup = $self->total_setup;
317 my $total_recur = $self->total_recur;
318 my $setup_show = $total_setup > 0 ? 1 : 0;
319 my $recur_show = $total_recur > 0 ? 1 : 0;
320 unless ($setup_show && $recur_show) {
321 foreach my $quotation_pkg ($self->quotation_pkg) {
322 $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
323 $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
324 last if $setup_show && $recur_show;
328 foreach my $pkg_tax (@setup_tax) {
329 if ($pkg_tax->setup_amount > 0) {
330 $total_setup += $pkg_tax->setup_amount;
332 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
333 'total_amount' => $pkg_tax->setup_amount,
340 'total_item' => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
341 'total_amount' => sprintf('%.2f',$total_setup),
342 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
346 #could/should add up the different recurring frequencies on lines of their own
347 # but this will cover the 95% cases for now
348 # label these with the frequency
349 foreach my $pkg_tax (@recur_tax) {
350 if ($pkg_tax->recur_amount > 0) {
351 $total_recur += $pkg_tax->recur_amount;
352 # an arbitrary part_pkg, but with the right frequency
354 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
356 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
357 'total_amount' => $pkg_tax->recur_amount,
364 'total_item' => $self->mt('Total Recurring'),
365 'total_amount' => sprintf('%.2f',$total_recur),
369 my $prorate_total = 0;
370 foreach my $quotation_pkg ($self->quotation_pkg) {
371 my $part_pkg = $quotation_pkg->part_pkg;
372 if ( $part_pkg->plan =~ /^(prorate|torrus|agent$)/
373 || $part_pkg->option('recur_method') eq 'prorate'
374 || ( $part_pkg->option('sync_bill_date')
376 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
384 if ( $prorate_total ) {
386 'total_item' => $self->mt('First payment (depending on day of month)'),
387 'total_amount' => [ sprintf('%.2f', $total_setup),
388 sprintf('%.2f', $total_setup + $total_recur)
394 'total_item' => $self->mt('First payment'),
395 'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
406 =item enable_previous
410 sub enable_previous { 0 }
412 =item convert_cust_main [ PARAMS ]
414 If this quotation already belongs to a customer, then returns that customer, as
415 an FS::cust_main object.
417 Otherwise, creates a new customer (FS::cust_main object and record, and
418 associated) based on this quotation's prospect, then orders this quotation's
419 packages as real packages for the customer.
421 If there is an error, returns an error message, otherwise, returns the
422 newly-created FS::cust_main object.
424 Accepts the same params as L</order>.
428 sub convert_cust_main {
430 my $params = shift || {};
432 my $cust_main = $self->cust_main;
433 return $cust_main if $cust_main; #already converted, don't again
435 my $oldAutoCommit = $FS::UID::AutoCommit;
436 local $FS::UID::AutoCommit = 0;
439 $cust_main = $self->prospect_main->convert_cust_main;
440 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
441 $dbh->rollback if $oldAutoCommit;
445 $self->prospectnum('');
446 $self->custnum( $cust_main->custnum );
447 my $error = $self->replace || $self->order(undef,$params);
449 $dbh->rollback if $oldAutoCommit;
453 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
459 =item order [ HASHREF ] [ PARAMS ]
461 This method is for use with quotations which are already associated with a customer.
463 Orders this quotation's packages as real packages for the customer.
465 If there is an error, returns an error message, otherwise returns false.
467 If HASHREF is passed, it will be filled with a hash mapping the
468 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
471 If PARAMS hashref is passed, the following params are accepted:
473 onhold - if true, suspends newly ordered packages
479 my $pkgnum_map = shift || {};
480 my $params = shift || {};
481 my $details_map = {};
483 tie my %all_cust_pkg, 'Tie::RefHash';
484 foreach my $quotation_pkg ($self->quotation_pkg) {
485 my $cust_pkg = FS::cust_pkg->new;
486 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
488 # details will be copied below, after package is ordered
489 $details_map->{ $quotation_pkg->quotationpkgnum } = [
490 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
493 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
494 $cust_pkg->set( $_, $quotation_pkg->get($_) );
497 # currently only one discount each
498 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
499 if ( $pkg_discount ) {
500 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
503 $all_cust_pkg{$cust_pkg} = []; # no services
506 local $SIG{HUP} = 'IGNORE';
507 local $SIG{INT} = 'IGNORE';
508 local $SIG{QUIT} = 'IGNORE';
509 local $SIG{TERM} = 'IGNORE';
510 local $SIG{TSTP} = 'IGNORE';
511 local $SIG{PIPE} = 'IGNORE';
513 my $oldAutoCommit = $FS::UID::AutoCommit;
514 local $FS::UID::AutoCommit = 0;
517 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
520 # copy details (copy_on_order filtering handled above)
521 foreach my $quotationpkgnum (keys %$details_map) {
522 next unless @{$details_map->{$quotationpkgnum}};
523 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
525 @{$details_map->{$quotationpkgnum}}
531 if ($$params{'onhold'}) {
532 foreach my $quotationpkgnum (keys %$pkgnum_map) {
534 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
539 $dbh->rollback if $oldAutoCommit;
543 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
545 foreach my $quotationpkgnum (keys %$pkgnum_map) {
546 # convert the objects to just pkgnums
547 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
548 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
561 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
566 One-time charges, like FS::cust_main::charge()
570 #super false laziness w/cust_main::charge
573 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
574 my ( $pkg, $comment, $additional );
575 my ( $setuptax, $taxclass ); #internal taxes
576 my ( $taxproduct, $override ); #vendor (CCH) taxes
578 my $cust_pkg_ref = '';
579 my ( $bill_now, $invoice_terms ) = ( 0, '' );
581 if ( ref( $_[0] ) ) {
582 $amount = $_[0]->{amount};
583 $setup_cost = $_[0]->{setup_cost};
584 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
585 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
586 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
587 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
588 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
589 : '$'. sprintf("%.2f",$amount);
590 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
591 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
592 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
593 $additional = $_[0]->{additional} || [];
594 $taxproduct = $_[0]->{taxproductnum};
595 $override = { '' => $_[0]->{tax_override} };
596 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
597 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
598 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
599 $locationnum = $_[0]->{locationnum};
605 $pkg = @_ ? shift : 'One-time charge';
606 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
608 $taxclass = @_ ? shift : '';
612 local $SIG{HUP} = 'IGNORE';
613 local $SIG{INT} = 'IGNORE';
614 local $SIG{QUIT} = 'IGNORE';
615 local $SIG{TERM} = 'IGNORE';
616 local $SIG{TSTP} = 'IGNORE';
617 local $SIG{PIPE} = 'IGNORE';
619 my $oldAutoCommit = $FS::UID::AutoCommit;
620 local $FS::UID::AutoCommit = 0;
623 my $part_pkg = new FS::part_pkg ( {
625 'comment' => $comment,
629 'classnum' => ( $classnum ? $classnum : '' ),
630 'setuptax' => $setuptax,
631 'taxclass' => $taxclass,
632 'taxproductnum' => $taxproduct,
633 'setup_cost' => $setup_cost,
636 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
637 ( 0 .. @$additional - 1 )
639 'additional_count' => scalar(@$additional),
640 'setup_fee' => $amount,
643 my $error = $part_pkg->insert( options => \%options,
644 tax_overrides => $override,
647 $dbh->rollback if $oldAutoCommit;
651 my $pkgpart = $part_pkg->pkgpart;
654 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
656 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
657 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
658 $error = $type_pkgs->insert;
660 $dbh->rollback if $oldAutoCommit;
665 #except for DIFF, eveything above is idential to cust_main version
666 #but below is our own thing pretty much (adding a quotation package instead
667 # of ordering a customer package, no "bill now")
669 my $quotation_pkg = new FS::quotation_pkg ( {
670 'quotationnum' => $self->quotationnum,
671 'pkgpart' => $pkgpart,
672 'quantity' => $quantity,
673 #'start_date' => $start_date,
674 #'no_auto' => $no_auto,
675 'locationnum'=> $locationnum,
678 $error = $quotation_pkg->insert;
680 $dbh->rollback if $oldAutoCommit;
682 #} elsif ( $cust_pkg_ref ) {
683 # ${$cust_pkg_ref} = $cust_pkg;
686 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
693 Disables this quotation (sets disabled to Y, which hides the quotation on
694 prospects and customers).
696 If there is an error, returns an error message, otherwise returns false.
702 $self->disabled('Y');
708 Enables this quotation.
710 If there is an error, returns an error message, otherwise returns false.
722 Calculates current prices for all items on this quotation, including
723 discounts and taxes, and updates the quotation_pkg records accordingly.
729 my $conf = FS::Conf->new;
732 my $oldAutoCommit = $FS::UID::AutoCommit;
733 local $FS::UID::AutoCommit = 0;
735 # bring individual items up to date (set setup/recur and discounts)
736 my @quotation_pkg = $self->quotation_pkg;
737 foreach my $pkg (@quotation_pkg) {
738 my $error = $pkg->estimate;
740 $dbh->rollback if $oldAutoCommit;
741 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
744 # delete old tax records
745 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
746 $error = $quotation_pkg_tax->delete;
748 $dbh->rollback if $oldAutoCommit;
749 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
754 # annoyingly duplicates handle_taxes--fix this in 4.x
755 if ( $conf->exists('enable_taxproducts') ) {
756 warn "can't calculate external taxes for quotations yet\n";
761 my %taxnum_exemptions; # for monthly exemptions; as yet unused
763 foreach my $pkg (@quotation_pkg) {
764 my $location = $pkg->cust_location;
766 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
767 my @loc_keys = qw( district city county state country);
768 my %taxhash = map { $_ => $location->$_ } @loc_keys;
769 $taxhash{'taxclass'} = $part_item->taxclass;
771 my %taxhash_elim = %taxhash;
772 my @elim = qw( district city county state );
774 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
775 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
776 #then try a match without taxclass
777 my %no_taxclass = %taxhash_elim;
778 $no_taxclass{ 'taxclass' } = '';
779 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
782 $taxhash_elim{ shift(@elim) } = '';
783 } while ( !scalar(@taxes) && scalar(@elim) );
785 foreach my $tax_def (@taxes) {
786 my $taxnum = $tax_def->taxnum;
787 $taxnum_exemptions{$taxnum} ||= [];
789 # XXX do some kind of equivalent to set_exemptions here
790 # but for now just declare that there are no exemptions,
791 # and then hack the taxable amounts if the package def
792 # excludes setup/recur
793 $pkg->set('cust_tax_exempt_pkg', []);
795 if ( $part_item->setuptax or $tax_def->setuptax ) {
796 $pkg->set('unitsetup', 0);
798 if ( $part_item->recurtax or $tax_def->recurtax ) {
799 $pkg->set('unitrecur', 0);
803 foreach my $pass (qw(first recur)) {
804 if ($pass eq 'recur') {
805 $pkg->set('unitsetup', 0);
808 my $taxline = $tax_def->taxline(
810 exemptions => $taxnum_exemptions{$taxnum}
812 if ($taxline and !ref($taxline)) {
813 $dbh->rollback if $oldAutoCommit;
814 die "error calculating '".$tax_def->taxname .
815 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
817 $taxline{$pass} = $taxline;
820 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
821 quotationpkgnum => $pkg->quotationpkgnum,
822 itemdesc => ($tax_def->taxname || 'Tax'),
824 taxtype => ref($tax_def),
826 my $setup_amount = 0;
827 my $recur_amount = 0;
828 if ($taxline{first}) {
829 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
831 if ($taxline{recur}) {
832 $recur_amount = $taxline{recur}->setup;
833 $setup_amount -= $recur_amount; # to get the actual setup amount
835 if ( $recur_amount > 0 or $setup_amount > 0 ) {
836 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
837 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
839 my $error = $quotation_pkg_tax->insert;
841 $dbh->rollback if $oldAutoCommit;
842 die "error recording '".$tax_def->taxname .
843 "' for pkgpart '".$pkg->pkgpart."': $error\n";
845 } # else there are no non-zero taxes; continue
849 $dbh->commit if $oldAutoCommit;
860 =item search_sql_where HASHREF
862 Class method which returns an SQL WHERE fragment to search for parameters
863 specified in HASHREF. Valid parameters are
869 List reference of start date, end date, as UNIX timestamps.
879 List reference of charged limits (exclusive).
883 List reference of charged limits (exclusive).
887 flag, return open invoices only
891 flag, return net invoices only
899 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
903 sub search_sql_where {
904 my($class, $param) = @_;
906 # warn "$me search_sql_where called with params: \n".
907 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
913 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
914 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
918 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
919 # push @search, "cust_main.refnum = $1";
923 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
924 push @search, "quotation.prospectnum = $1";
928 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
929 push @search, "cust_bill.custnum = $1";
933 if ( $param->{_date} ) {
934 my($beginning, $ending) = @{$param->{_date}};
936 push @search, "quotation._date >= $beginning",
937 "quotation._date < $ending";
941 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
942 push @search, "quotation.quotationnum >= $1";
944 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
945 push @search, "quotation.quotationnum <= $1";
949 # if ( $param->{charged} ) {
950 # my @charged = ref($param->{charged})
951 # ? @{ $param->{charged} }
952 # : ($param->{charged});
954 # push @search, map { s/^charged/cust_bill.charged/; $_; }
958 my $owed_sql = FS::cust_bill->owed_sql;
961 push @search, "quotation._date < ". (time-86400*$param->{'days'})
964 #agent virtualization
965 my $curuser = $FS::CurrentUser::CurrentUser;
966 #false laziness w/search/quotation.html
967 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
968 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
971 join(' AND ', @search );
977 Return line item hashes for each package on this quotation. Differs from the
978 base L<FS::Template_Mixin> version in that it recalculates each quoted package
979 first, and doesn't implement the "condensed" option.
984 my ($self, %options) = @_;
986 # run it through the Template_Mixin engine
987 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
996 L<FS::Record>, schema.html from the base documentation.