+ $self->disabled('Y');
+ $self->replace();
+}
+
+=item enable
+
+Enables this quotation.
+
+If there is an error, returns an error message, otherwise returns false.
+
+=cut
+
+sub enable {
+ my $self = shift;
+ $self->disabled('');
+ $self->replace();
+}
+
+=item estimate
+
+Calculates current prices for all items on this quotation, including
+discounts and taxes, and updates the quotation_pkg records accordingly.
+
+=cut
+
+sub estimate {
+ my $self = shift;
+ my $conf = FS::Conf->new;
+
+ my %pkgnum_of; # quotationpkgnum => temporary pkgnum
+
+ my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
+
+ my @return_bill = ([]);
+ my $error;
+
+ ###### BEGIN TRANSACTION ######
+ local $@;
+ local $SIG{__DIE__};
+ eval {
+ my $temp_dbh = myconnect();
+ local $FS::UID::dbh = $temp_dbh;
+ local $FS::UID::AutoCommit = 0;
+
+ my $fake_self = FS::quotation->new({ $self->hash });
+
+ # if this is a prospect, make them into a customer for now
+ # XXX prospects currently can't have service locations
+ my $cust_or_prospect = $self->cust_or_prospect;
+ my $cust_main;
+ if ( $cust_or_prospect->isa('FS::prospect_main') ) {
+ $cust_main = $cust_or_prospect->convert_cust_main;
+ die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
+ $fake_self->set('prospectnum', '');
+ $fake_self->set('custnum', $cust_main->custnum);
+ } else {
+ $cust_main = $cust_or_prospect;
+ }
+
+ # order packages
+ $error = $fake_self->order(\%pkgnum_of);
+ die "$error (simulating package order)\n" if $error;
+
+ my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
+
+ # simulate the first bill
+ my %bill_opt = (
+ 'estimate' => 1,
+ 'pkg_list' => \@new_pkgs,
+ 'time' => time, # an option to adjust this?
+ 'return_bill' => $return_bill[0],
+ 'no_usage_reset' => 1,
+ );
+ $error = $cust_main->bill(%bill_opt);
+ die "$error (simulating initial billing)\n" if $error;
+
+ # pick dates for future bills
+ my %next_bill_pkgs;
+ foreach (@new_pkgs) {
+ my $bill = $_->get('bill');
+ next if !$bill;
+ push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
+ }
+
+ my $i = 1;
+ foreach my $next_bill (keys %next_bill_pkgs) {
+ $bill_opt{'time'} = $next_bill;
+ $bill_opt{'return_bill'} = $return_bill[$i] = [];
+ $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
+ $error = $cust_main->bill(%bill_opt);
+ die "$error (simulating recurring billing cycle $i)\n" if $error;
+ $i++;
+ }
+
+ $temp_dbh->rollback;
+ };
+ return $@ if $@;
+ ###### END TRANSACTION ######
+ my %quotationpkgnum_of = reverse %pkgnum_of;
+
+ if ($DEBUG) {
+ warn "pkgnums:\n".Dumper(\%pkgnum_of);
+ warn Dumper(\@return_bill);
+ }
+
+ # Careful: none of the foreign keys in here are correct outside the sandbox.
+ # We have a translation table for pkgnums; all others are total lies.
+
+ my %quotation_pkg; # quotationpkgnum => quotation_pkg
+ foreach my $qp ($self->quotation_pkg) {
+ $quotation_pkg{$qp->quotationpkgnum} = $qp;
+ $qp->set($_, 0) foreach qw(unitsetup unitrecur);
+ $qp->set('freq', '');
+ # flush old tax records
+ foreach ($qp->quotation_pkg_tax) {
+ $error = $_->delete;
+ return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
+ if $error;
+ }
+ }
+
+ my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
+ my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
+
+ for (my $i = 0; $i < scalar(@return_bill); $i++) {
+ my $this_bill = $return_bill[$i]->[0];
+ if (!$this_bill) {
+ warn "$me billing cycle $i produced no invoice\n";
+ next;
+ }
+
+ my @nonpkg_lines;
+ my %cust_bill_pkg;
+ foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
+ my $pkgnum = $cust_bill_pkg->pkgnum;
+ $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
+ if ( !$pkgnum ) {
+ # taxes/fees; come back to it
+ push @nonpkg_lines, $cust_bill_pkg;
+ next;
+ }
+ my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
+ my $qp = $quotation_pkg{$quotationpkgnum};
+ if (!$qp) {
+ # XXX supplemental packages could do this (they have separate pkgnums)
+ # handle that special case at some point
+ warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
+ next;
+ }
+ if ( $i == 0 ) {
+ # then this is the first (setup) invoice
+ $qp->set('start_date', $cust_bill_pkg->sdate);
+ $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
+ # pkgpart_override is a possibility
+ } else {
+ # recurring invoice (should be only one of these per package, though
+ # it may have multiple lineitems with the same pkgnum)
+ $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
+ }
+
+ # discounts
+ if ( $cust_bill_pkg->get('discounts') ) {
+ # discount records are generated as (setup, recur).
+ # well, not always, sometimes it's just (recur), but fixing this
+ # is horribly invasive.
+ my $discount = $cust_bill_pkg->get('discounts')->[0];
+
+ if ( $discount ) {
+ # find the quotation_pkg_discount record for this billing pass...
+ my $setuprecur = $i ? 'recur' : 'setup';
+ my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
+ ||= qsearchs('quotation_pkg_discount', {
+ 'quotationpkgnum' => $quotationpkgnum,
+ 'setuprecur' => $setuprecur,
+ });
+
+ if (!$qpd) { #can't happen
+ warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
+ }
+ if ($qpd) {
+ $qpd->set('amount', $discount->amount);
+ }
+ }
+ } # end of discount stuff
+
+ }
+
+ # create tax records
+ foreach my $cust_bill_pkg (@nonpkg_lines) {
+
+ my $itemdesc = $cust_bill_pkg->itemdesc;
+
+ if ($cust_bill_pkg->feepart) {
+ warn "$me simulated bill included a non-package fee (feepart ".
+ $cust_bill_pkg->feepart.")\n";
+ next;
+ }
+ my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
+ $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
+ [];
+ # breadth-first unrolled recursion:
+ # take each tax link and any tax-on-tax descendants, and merge them
+ # into a single quotation_pkg_tax record for each pkgnum/taxname
+ # combination
+ while (my $tax_link = shift @$links) {
+ my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
+ or die "$me unable to resolve tax link\n";
+ if ($target->pkgnum) {
+ my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
+ # create this if there isn't one yet
+ my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
+ FS::quotation_pkg_tax->new({
+ quotationpkgnum => $quotationpkgnum,
+ itemdesc => $itemdesc,
+ setup_amount => 0,
+ recur_amount => 0,
+ });
+ if ( $i == 0 ) { # first invoice
+ $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
+ } else { # subsequent invoices
+ # this isn't perfectly accurate, but that's why it's an estimate
+ $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
+ $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
+ $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
+ }
+ } elsif ($target->feepart) {
+ # do nothing; we already warned for the fee itself
+ } else {
+ # tax on tax: the tax target is another tax item.
+ # since this is an estimate, I'm just going to assign it to the
+ # first of the underlying packages. (RT#5243 is why we can't have
+ # nice things.)
+ my $sublinks = $target->cust_bill_pkg_tax_rate_location;
+ if ($sublinks and $sublinks->[0]) {
+ $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
+ push @$links, $tax_link; #try again
+ } else {
+ warn "$me unable to assign tax on tax; ignoring\n";
+ }
+ }
+ } # while my $tax_link
+
+ } # foreach my $cust_bill_pkg
+ }
+ foreach my $quotation_pkg (values %quotation_pkg) {
+ $error = $quotation_pkg->replace;
+ return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
+ if $error;
+ }
+ foreach (values %quotation_pkg_discount) {
+ # { setup => one, recur => another }
+ foreach my $quotation_pkg_discount (values %$_) {
+ $error = $quotation_pkg_discount->replace;
+ return "$error (recording estimated discount)"
+ if $error;
+ }
+ }
+ foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
+ $error = $quotation_pkg_tax->insert;
+ return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
+ if $error;
+ }
+ return;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+
+=item search_sql_where HASHREF
+
+Class method which returns an SQL WHERE fragment to search for parameters
+specified in HASHREF. Valid parameters are
+
+=over 4
+
+=item _date
+
+List reference of start date, end date, as UNIX timestamps.
+
+=item invnum_min
+
+=item invnum_max
+
+=item agentnum
+
+=item charged
+
+List reference of charged limits (exclusive).
+
+=item owed
+
+List reference of charged limits (exclusive).
+
+=item open
+
+flag, return open invoices only
+
+=item net
+
+flag, return net invoices only
+
+=item days
+
+=item newest_percust
+
+=back
+
+Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+
+=cut
+
+sub search_sql_where {
+ my($class, $param) = @_;
+ #if ( $DEBUG ) {
+ # warn "$me search_sql_where called with params: \n".
+ # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
+ #}
+
+ my @search = ();
+
+ #agentnum
+ if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+ push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
+ }
+
+# #refnum
+# if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
+# push @search, "cust_main.refnum = $1";
+# }
+
+ #prospectnum
+ if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
+ push @search, "quotation.prospectnum = $1";
+ }
+
+ #custnum
+ if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
+ push @search, "cust_bill.custnum = $1";
+ }
+
+ #_date
+ if ( $param->{_date} ) {
+ my($beginning, $ending) = @{$param->{_date}};
+
+ push @search, "quotation._date >= $beginning",
+ "quotation._date < $ending";
+ }
+
+ #quotationnum
+ if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
+ push @search, "quotation.quotationnum >= $1";
+ }
+ if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
+ push @search, "quotation.quotationnum <= $1";
+ }
+
+# #charged
+# if ( $param->{charged} ) {
+# my @charged = ref($param->{charged})
+# ? @{ $param->{charged} }
+# : ($param->{charged});
+#
+# push @search, map { s/^charged/cust_bill.charged/; $_; }
+# @charged;
+# }
+
+ my $owed_sql = FS::cust_bill->owed_sql;
+
+ #days
+ push @search, "quotation._date < ". (time-86400*$param->{'days'})
+ if $param->{'days'};
+
+ #agent virtualization
+ my $curuser = $FS::CurrentUser::CurrentUser;
+ #false laziness w/search/quotation.html
+ push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
+ ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
+ ' ) ';
+
+ join(' AND ', @search );
+
+}
+
+=item _items_pkg
+
+Return line item hashes for each package on this quotation.
+
+=cut
+
+sub _items_pkg {
+ my ($self, %options) = @_;
+ my $escape = $options{'escape_function'};
+ my $locale = $self->cust_or_prospect->locale;
+
+ my $preref = $options{'preref_callback'};
+
+ my $section = $options{'section'};
+ my $freq = $section->{'category'};
+ my @pkgs = $self->quotation_pkg;
+ my @items;
+ die "_items_pkg called without section->{'category'}"
+ unless defined $freq;
+
+ my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
+ # like we should have done in the first place
+
+ foreach my $quotation_pkg (@pkgs) {
+ my $part_pkg = $quotation_pkg->part_pkg;
+ my @details = $quotation_pkg->details;
+ my $setuprecur;
+ my $this_item = {
+ 'pkgnum' => $quotation_pkg->quotationpkgnum,
+ 'description' => $quotation_pkg->desc($locale),
+ 'ext_description' => \@details,
+ 'quantity' => $quotation_pkg->quantity,
+ };
+ if ($freq eq '0') {
+ # setup/one-time
+ $setuprecur = 'setup';
+ if ($part_pkg->freq ne '0') {
+ # indicate that it's a setup fee on a recur package (cust_bill does
+ # this too)
+ $this_item->{'description'} .= ' Setup';
+ }
+ } else {
+ # recur for this frequency
+ next if $freq ne $part_pkg->freq;
+ $setuprecur = 'recur';
+ }
+
+ $this_item->{'unit_amount'} = sprintf('%.2f',
+ $quotation_pkg->get('unit'.$setuprecur));
+ $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
+ * $quotation_pkg->quantity);
+ next if $this_item->{'amount'} == 0 and !(
+ $setuprecur eq 'setup'
+ ? $quotation_pkg->setup_show_zero
+ : $quotation_pkg->recur_show_zero
+ );
+
+ if ( $preref ) {
+ $this_item->{'preref_html'} = &$preref($quotation_pkg);
+ }
+
+ push @items, $this_item;
+ my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
+ if ($discount) {
+ $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
+ push @items, $discount;
+ }
+
+ # each quotation_pkg_tax has two amounts: the amount charged on the
+ # setup invoice, and the amount on the recurring invoice.
+ foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
+ my $this_tax = $tax_item{$qpt->itemdesc} ||= {
+ 'pkgnum' => 0,
+ 'description' => $qpt->itemdesc,
+ 'ext_description' => [],
+ 'amount' => 0,
+ };
+ $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
+ }
+ } # foreach $quotation_pkg
+
+ foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
+ my $this_tax = $tax_item{$taxname};
+ $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
+ next if $this_tax->{'amount'} == 0;
+ push @items, $this_tax;
+ }
+
+ return @items;
+}
+
+sub _items_tax {
+ ();