one-time charges on quotations, RT#25561
[freeside.git] / FS / FS / quotation.pm
1 package FS::quotation;
2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
3            );
4
5 use strict;
6 use Tie::RefHash;
7 use FS::CurrentUser;
8 use FS::UID qw( dbh );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearchs );
11 use FS::cust_main;
12 use FS::cust_pkg;
13 use FS::quotation_pkg;
14 use FS::type_pkgs;
15
16 =head1 NAME
17
18 FS::quotation - Object methods for quotation records
19
20 =head1 SYNOPSIS
21
22   use FS::quotation;
23
24   $record = new FS::quotation \%hash;
25   $record = new FS::quotation { 'column' => 'value' };
26
27   $error = $record->insert;
28
29   $error = $new_record->replace($old_record);
30
31   $error = $record->delete;
32
33   $error = $record->check;
34
35 =head1 DESCRIPTION
36
37 An FS::quotation object represents a quotation.  FS::quotation inherits from
38 FS::Record.  The following fields are currently supported:
39
40 =over 4
41
42 =item quotationnum
43
44 primary key
45
46 =item prospectnum
47
48 prospectnum
49
50 =item custnum
51
52 custnum
53
54 =item _date
55
56 _date
57
58 =item disabled
59
60 disabled
61
62 =item usernum
63
64 usernum
65
66
67 =back
68
69 =head1 METHODS
70
71 =over 4
72
73 =item new HASHREF
74
75 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
76
77 Note that this stores the hash reference, not a distinct copy of the hash it
78 points to.  You can ask the object for a copy with the I<hash> method.
79
80 =cut
81
82 sub table { 'quotation'; }
83 sub notice_name { 'Quotation'; }
84 sub template_conf { 'quotation_'; }
85
86 =item insert
87
88 Adds this record to the database.  If there is an error, returns the error,
89 otherwise returns false.
90
91 =item delete
92
93 Delete this record from the database.
94
95 =item replace OLD_RECORD
96
97 Replaces the OLD_RECORD with this one in the database.  If there is an error,
98 returns the error, otherwise returns false.
99
100 =item check
101
102 Checks all fields to make sure this is a valid quotation.  If there is
103 an error, returns the error, otherwise returns false.  Called by the insert
104 and replace methods.
105
106 =cut
107
108 sub check {
109   my $self = shift;
110
111   my $error = 
112     $self->ut_numbern('quotationnum')
113     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
114     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
115     || $self->ut_numbern('_date')
116     || $self->ut_enum('disabled', [ '', 'Y' ])
117     || $self->ut_numbern('usernum')
118   ;
119   return $error if $error;
120
121   $self->_date(time) unless $self->_date;
122
123   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
124
125   return 'prospectnum or custnum must be specified'
126     if ! $self->prospectnum
127     && ! $self->custnum;
128
129   $self->SUPER::check;
130 }
131
132 =item prospect_main
133
134 =item cust_main
135
136 =item cust_bill_pkg
137
138 =cut
139
140 sub cust_bill_pkg { #actually quotation_pkg objects
141   shift->quotation_pkg(@_);
142 }
143
144 =item total_setup
145
146 =cut
147
148 sub total_setup {
149   my $self = shift;
150   $self->_total('setup');
151 }
152
153 =item total_recur [ FREQ ]
154
155 =cut
156
157 sub total_recur {
158   my $self = shift;
159 #=item total_recur [ FREQ ]
160   #my $freq = @_ ? shift : '';
161   $self->_total('recur');
162 }
163
164 sub _total {
165   my( $self, $method ) = @_;
166
167   my $total = 0;
168   $total += $_->$method() for $self->cust_bill_pkg;
169   sprintf('%.2f', $total);
170
171 }
172
173 sub email {
174   my $self = shift;
175   my $opt = shift || {};
176   if ($opt and !ref($opt)) {
177     die ref($self). '->email called with positional parameters';
178   }
179
180   my $conf = $self->conf;
181
182   my $from = delete $opt->{from};
183
184   # this is where we set the From: address
185   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
186          || $conf->config('invoice_from',   $self->cust_or_prospect->agentnum );
187
188   $self->SUPER::email( {
189     'from' => $from,
190     %$opt,
191   });
192
193 }
194
195 sub email_subject {
196   my $self = shift;
197
198   my $subject =
199     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
200       || 'Quotation';
201
202   #my $cust_main = $self->cust_main;
203   #my $name = $cust_main->name;
204   #my $name_short = $cust_main->name_short;
205   #my $invoice_number = $self->invnum;
206   #my $invoice_date = $self->_date_pretty;
207
208   eval qq("$subject");
209 }
210
211 =item cust_or_prosect
212
213 =cut
214
215 sub cust_or_prospect {
216   my $self = shift;
217   $self->custnum ? $self->cust_main : $self->prospect_main;
218 }
219
220 =item cust_or_prospect_label_link P
221
222 HTML links to either the customer or prospect.
223
224 Returns a list consisting of two elements.  The first is a text label for the
225 link, and the second is the URL.
226
227 =cut
228
229 sub cust_or_prospect_label_link {
230   my( $self, $p ) = @_;
231
232   if ( my $custnum = $self->custnum ) {
233     my $display_custnum = $self->cust_main->display_custnum;
234     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
235                    ? '#quotations'
236                    : ';show=quotations';
237     (
238       emt("View this customer (#[_1])",$display_custnum) =>
239         "${p}view/cust_main.cgi?custnum=$custnum$target"
240     );
241   } elsif ( my $prospectnum = $self->prospectnum ) {
242     (
243       emt("View this prospect (#[_1])",$prospectnum) =>
244         "${p}view/prospect_main.html?$prospectnum"
245     );
246   } else { #die?
247     ( '', '' );
248   }
249
250 }
251
252 #prevent things from falsely showing up as taxes, at least until we support
253 # quoting tax amounts..
254 sub _items_tax {
255   return ();
256 }
257 sub _items_nontax {
258   shift->cust_bill_pkg;
259 }
260
261 sub _items_total {
262   my( $self, $total_items ) = @_;
263
264   if ( $self->total_setup > 0 ) {
265     push @$total_items, {
266       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
267       'total_amount' => $self->total_setup,
268     };
269   }
270
271   #could/should add up the different recurring frequencies on lines of their own
272   # but this will cover the 95% cases for now
273   if ( $self->total_recur > 0 ) {
274     push @$total_items, {
275       'total_item'   => $self->mt('Total Recurring'),
276       'total_amount' => $self->total_recur,
277     };
278   }
279
280 }
281
282 =item enable_previous
283
284 =cut
285
286 sub enable_previous { 0 }
287
288 =item convert_cust_main
289
290 If this quotation already belongs to a customer, then returns that customer, as
291 an FS::cust_main object.
292
293 Otherwise, creates a new customer (FS::cust_main object and record, and
294 associated) based on this quotation's prospect, then orders this quotation's
295 packages as real packages for the customer.
296
297 If there is an error, returns an error message, otherwise, returns the
298 newly-created FS::cust_main object.
299
300 =cut
301
302 sub convert_cust_main {
303   my $self = shift;
304
305   my $cust_main = $self->cust_main;
306   return $cust_main if $cust_main; #already converted, don't again
307
308   my $oldAutoCommit = $FS::UID::AutoCommit;
309   local $FS::UID::AutoCommit = 0;
310   my $dbh = dbh;
311
312   $cust_main = $self->prospect_main->convert_cust_main;
313   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
314     $dbh->rollback if $oldAutoCommit;
315     return $cust_main;
316   }
317
318   $self->prospectnum('');
319   $self->custnum( $cust_main->custnum );
320   my $error = $self->replace || $self->order;
321   if ( $error ) {
322     $dbh->rollback if $oldAutoCommit;
323     return $error;
324   }
325
326   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
327
328   $cust_main;
329
330 }
331
332 =item order
333
334 This method is for use with quotations which are already associated with a customer.
335
336 Orders this quotation's packages as real packages for the customer.
337
338 If there is an error, returns an error message, otherwise returns false.
339
340 =cut
341
342 sub order {
343   my $self = shift;
344
345   tie my %cust_pkg, 'Tie::RefHash',
346     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
347                               quantity => $_->quantity,
348                            })
349             => [] #services
350         }
351       $self->quotation_pkg ;
352
353   $self->cust_main->order_pkgs( \%cust_pkg );
354
355 }
356
357 =item charge
358
359 One-time charges, like FS::cust_main::charge()
360
361 =cut
362
363 #super false laziness w/cust_main::charge
364 sub charge {
365   my $self = shift;
366   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
367   my ( $pkg, $comment, $additional );
368   my ( $setuptax, $taxclass );   #internal taxes
369   my ( $taxproduct, $override ); #vendor (CCH) taxes
370   my $no_auto = '';
371   my $cust_pkg_ref = '';
372   my ( $bill_now, $invoice_terms ) = ( 0, '' );
373   my $locationnum;
374   if ( ref( $_[0] ) ) {
375     $amount     = $_[0]->{amount};
376     $setup_cost = $_[0]->{setup_cost};
377     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
378     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
379     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
380     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
381     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
382                                            : '$'. sprintf("%.2f",$amount);
383     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
384     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
385     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
386     $additional = $_[0]->{additional} || [];
387     $taxproduct = $_[0]->{taxproductnum};
388     $override   = { '' => $_[0]->{tax_override} };
389     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
390     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
391     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
392     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
393   } else {
394     $amount     = shift;
395     $setup_cost = '';
396     $quantity   = 1;
397     $start_date = '';
398     $pkg        = @_ ? shift : 'One-time charge';
399     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
400     $setuptax   = '';
401     $taxclass   = @_ ? shift : '';
402     $additional = [];
403   }
404
405   local $SIG{HUP} = 'IGNORE';
406   local $SIG{INT} = 'IGNORE';
407   local $SIG{QUIT} = 'IGNORE';
408   local $SIG{TERM} = 'IGNORE';
409   local $SIG{TSTP} = 'IGNORE';
410   local $SIG{PIPE} = 'IGNORE';
411
412   my $oldAutoCommit = $FS::UID::AutoCommit;
413   local $FS::UID::AutoCommit = 0;
414   my $dbh = dbh;
415
416   my $part_pkg = new FS::part_pkg ( {
417     'pkg'           => $pkg,
418     'comment'       => $comment,
419     'plan'          => 'flat',
420     'freq'          => 0,
421     'disabled'      => 'Y',
422     'classnum'      => ( $classnum ? $classnum : '' ),
423     'setuptax'      => $setuptax,
424     'taxclass'      => $taxclass,
425     'taxproductnum' => $taxproduct,
426     'setup_cost'    => $setup_cost,
427   } );
428
429   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
430                         ( 0 .. @$additional - 1 )
431                   ),
432                   'additional_count' => scalar(@$additional),
433                   'setup_fee' => $amount,
434                 );
435
436   my $error = $part_pkg->insert( options       => \%options,
437                                  tax_overrides => $override,
438                                );
439   if ( $error ) {
440     $dbh->rollback if $oldAutoCommit;
441     return $error;
442   }
443
444   my $pkgpart = $part_pkg->pkgpart;
445
446   #DIFF
447   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
448
449   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
450     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
451     $error = $type_pkgs->insert;
452     if ( $error ) {
453       $dbh->rollback if $oldAutoCommit;
454       return $error;
455     }
456   }
457
458   #except for DIFF, eveything above is idential to cust_main version
459   #but below is our own thing pretty much (adding a quotation package instead
460   # of ordering a customer package, no "bill now")
461
462   my $quotation_pkg = new FS::quotation_pkg ( {
463     'quotationnum'  => $self->quotationnum,
464     'pkgpart'       => $pkgpart,
465     'quantity'      => $quantity,
466     #'start_date' => $start_date,
467     #'no_auto'    => $no_auto,
468     'locationnum'=> $locationnum,
469   } );
470
471   $error = $quotation_pkg->insert;
472   if ( $error ) {
473     $dbh->rollback if $oldAutoCommit;
474     return $error;
475   #} elsif ( $cust_pkg_ref ) {
476   #  ${$cust_pkg_ref} = $cust_pkg;
477   }
478
479   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
480   return '';
481
482 }
483
484 =item disable
485
486 Disables this quotation (sets disabled to Y, which hides the quotation on
487 prospects and customers).
488
489 If there is an error, returns an error message, otherwise returns false.
490
491 =cut
492
493 sub disable {
494   my $self = shift;
495   $self->disabled('Y');
496   $self->replace();
497 }
498
499 =item enable
500
501 Enables this quotation.
502
503 If there is an error, returns an error message, otherwise returns false.
504
505 =cut
506
507 sub enable {
508   my $self = shift;
509   $self->disabled('');
510   $self->replace();
511 }
512
513 =back
514
515 =head1 CLASS METHODS
516
517 =over 4
518
519
520 =item search_sql_where HASHREF
521
522 Class method which returns an SQL WHERE fragment to search for parameters
523 specified in HASHREF.  Valid parameters are
524
525 =over 4
526
527 =item _date
528
529 List reference of start date, end date, as UNIX timestamps.
530
531 =item invnum_min
532
533 =item invnum_max
534
535 =item agentnum
536
537 =item charged
538
539 List reference of charged limits (exclusive).
540
541 =item owed
542
543 List reference of charged limits (exclusive).
544
545 =item open
546
547 flag, return open invoices only
548
549 =item net
550
551 flag, return net invoices only
552
553 =item days
554
555 =item newest_percust
556
557 =back
558
559 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
560
561 =cut
562
563 sub search_sql_where {
564   my($class, $param) = @_;
565   #if ( $DEBUG ) {
566   #  warn "$me search_sql_where called with params: \n".
567   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
568   #}
569
570   my @search = ();
571
572   #agentnum
573   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
574     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
575   }
576
577 #  #refnum
578 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
579 #    push @search, "cust_main.refnum = $1";
580 #  }
581
582   #prospectnum
583   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
584     push @search, "quotation.prospectnum = $1";
585   }
586
587   #custnum
588   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
589     push @search, "cust_bill.custnum = $1";
590   }
591
592   #_date
593   if ( $param->{_date} ) {
594     my($beginning, $ending) = @{$param->{_date}};
595
596     push @search, "quotation._date >= $beginning",
597                   "quotation._date <  $ending";
598   }
599
600   #quotationnum
601   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
602     push @search, "quotation.quotationnum >= $1";
603   }
604   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
605     push @search, "quotation.quotationnum <= $1";
606   }
607
608 #  #charged
609 #  if ( $param->{charged} ) {
610 #    my @charged = ref($param->{charged})
611 #                    ? @{ $param->{charged} }
612 #                    : ($param->{charged});
613 #
614 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
615 #                      @charged;
616 #  }
617
618   my $owed_sql = FS::cust_bill->owed_sql;
619
620   #days
621   push @search, "quotation._date < ". (time-86400*$param->{'days'})
622     if $param->{'days'};
623
624   #agent virtualization
625   my $curuser = $FS::CurrentUser::CurrentUser;
626   #false laziness w/search/quotation.html
627   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
628                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
629                ' )    ';
630
631   join(' AND ', @search );
632
633 }
634
635 =back
636
637 =head1 BUGS
638
639 =head1 SEE ALSO
640
641 L<FS::Record>, schema.html from the base documentation.
642
643 =cut
644
645 1;
646