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