RT#39831 Quotation extra information for line items
[freeside.git] / FS / FS / quotation_pkg.pm
1 package FS::quotation_pkg;
2 use base qw( FS::TemplateItem_Mixin FS::Record );
3
4 use strict;
5 use FS::Record qw( qsearchs qsearch dbh );
6 use FS::part_pkg;
7 use FS::quotation_pkg_discount; #so its loaded when TemplateItem_Mixin needs it
8 use FS::quotation_pkg_detail;
9 use List::Util qw(sum);
10
11 =head1 NAME
12
13 FS::quotation_pkg - Object methods for quotation_pkg records
14
15 =head1 SYNOPSIS
16
17   use FS::quotation_pkg;
18
19   $record = new FS::quotation_pkg \%hash;
20   $record = new FS::quotation_pkg { 'column' => 'value' };
21
22   $error = $record->insert;
23
24   $error = $new_record->replace($old_record);
25
26   $error = $record->delete;
27
28   $error = $record->check;
29
30 =head1 DESCRIPTION
31
32 An FS::quotation_pkg object represents a quotation package.
33 FS::quotation_pkg inherits from FS::Record.  The following fields are currently
34 supported:
35
36 =over 4
37
38 =item quotationpkgnum
39
40 primary key
41
42 =item pkgpart
43
44 pkgpart (L<FS::part_pkg>) of the package
45
46 =item locationnum
47
48 locationnum (L<FS::cust_location>) where the package will be in service
49
50 =item start_date
51
52 expected start date for the package, as a timestamp
53
54 =item contract_end
55
56 contract end date
57
58 =item quantity
59
60 quantity
61
62 =item waive_setup
63
64 'Y' to waive the setup fee
65
66 =item unitsetup
67
68 The amount per package that will be charged in setup/one-time fees.
69
70 =item unitrecur
71
72 The amount per package that will be charged per billing cycle.
73
74 =item freq
75
76 The length of the billing cycle. If zero it's a one-time charge; if any 
77 other number it's that many months; other values are in L<FS::Misc::pkg_freqs>.
78
79 =back
80
81 =head1 METHODS
82
83 =over 4
84
85 =item new HASHREF
86
87 Creates a new quotation package.  To add the quotation package to the database,
88 see L<"insert">.
89
90 Note that this stores the hash reference, not a distinct copy of the hash it
91 points to.  You can ask the object for a copy with the I<hash> method.
92
93 =cut
94
95 sub table { 'quotation_pkg'; }
96
97 sub display_table         { 'quotation_pkg'; }
98
99 #forget it, just overriding cust_bill_pkg_display entirely
100 #sub display_table_orderby { 'quotationpkgnum'; } # something else?
101 #                                                 #  (for invoice display order)
102
103 sub discount_table        { 'quotation_pkg_discount'; }
104 sub detail_table          { 'quotation_pkg_detail'; }
105
106 =item insert
107
108 Adds this record to the database.  If there is an error, returns the error,
109 otherwise returns false.
110
111 =cut
112
113 sub insert {
114   my ($self, %options) = @_;
115
116   my $dbh = dbh;
117   my $oldAutoCommit = $FS::UID::AutoCommit;
118   local $FS::UID::AutoCommit = 0;
119
120   my $error = $self->SUPER::insert;
121
122   if ( !$error and ($self->setup_discountnum || $self->recur_discountnum) ) {
123       #warn "inserting discount\n";
124     $error = $self->insert_discount;
125     $error .= ' (setting discount)' if $error;
126   }
127
128   if ( $error ) {
129     $dbh->rollback if $oldAutoCommit;
130     return $error;
131   } else {
132     $dbh->commit if $oldAutoCommit;
133     return '';
134   }
135 }
136
137 =item delete
138
139 Delete this record from the database.
140
141 =cut
142
143 sub delete {
144   my $self = shift;
145
146   my $dbh = dbh;
147   my $oldAutoCommit = $FS::UID::AutoCommit;
148   local $FS::UID::AutoCommit = 0;
149
150   my $error = $self->delete_details;
151   if ( $error ) {
152     $dbh->rollback if $oldAutoCommit;
153     return $error;
154   }
155
156   foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) {
157     $error = $_->delete;
158     if ( $error ) {
159       $dbh->rollback if $oldAutoCommit;
160       return $error . ' (deleting discount)';
161     }
162   }
163
164   $error = $self->SUPER::delete;
165   if ( $error ) {
166     $dbh->rollback if $oldAutoCommit;
167     return $error;
168   } else {
169     $dbh->commit if $oldAutoCommit;
170   }
171   
172   $self->quotation->estimate;
173 }
174
175 =item replace OLD_RECORD
176
177 Replaces the OLD_RECORD with this one in the database.  If there is an error,
178 returns the error, otherwise returns false.
179
180 =item check
181
182 Checks all fields to make sure this is a valid quotation package.  If there is
183 an error, returns the error, otherwise returns false.  Called by the insert
184 and replace methods.
185
186 =cut
187
188 sub check {
189   my $self = shift;
190
191   my @freqs = ('', keys (%{ FS::Misc->pkg_freqs }));
192
193   my $error = 
194     $self->ut_numbern('quotationpkgnum')
195     || $self->ut_foreign_key(  'quotationnum', 'quotation',    'quotationnum' )
196     || $self->ut_foreign_key(  'pkgpart',      'part_pkg',     'pkgpart'      )
197     || $self->ut_foreign_keyn( 'locationnum', 'cust_location', 'locationnum'  )
198     || $self->ut_numbern('start_date')
199     || $self->ut_numbern('contract_end')
200     || $self->ut_numbern('quantity')
201     || $self->ut_moneyn('unitsetup')
202     || $self->ut_moneyn('unitrecur')
203     || $self->ut_enum('freq', \@freqs)
204     || $self->ut_enum('waive_setup', [ '', 'Y'] )
205   ;
206
207   if ($self->locationnum eq '') {
208     # use the customer default
209     my $quotation = $self->quotation;
210     if ($quotation->custnum) {
211       $self->set('locationnum', $quotation->cust_main->ship_locationnum);
212     } elsif ($quotation->prospectnum) {
213       # use the first non-disabled location for that prospect
214       my $cust_location = qsearchs('cust_location',
215         { prospectnum => $quotation->prospectnum,
216           disabled => '' });
217       $self->set('locationnum', $cust_location->locationnum) if $cust_location;
218     } # else the quotation is invalid
219   }
220
221   return $error if $error;
222
223   $self->SUPER::check;
224 }
225
226 #it looks redundant with a v4.x+ auto-generated method, but need to override
227 # FS::TemplateItem_Mixin's version
228 sub part_pkg {
229   my $self = shift;
230   qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart } );
231 }
232
233 sub desc {
234   my $self = shift;
235   $self->part_pkg->pkg;
236 }
237
238 =cut
239
240 =item insert_discount
241
242 Associates this package with a discount (see L<FS::cust_pkg_discount>,
243 possibly inserting a new discount on the fly (see L<FS::discount>). Properties
244 of the discount will be taken from this object.
245
246 =cut
247
248 sub insert_discount {
249   #my ($self, %options) = @_;
250   my $self = shift;
251
252   foreach my $x (qw(setup recur)) {
253     if ( my $discountnum = $self->get("${x}_discountnum") ) {
254       my $cust_pkg_discount = FS::quotation_pkg_discount->new( { 
255         'quotationpkgnum' => $self->quotationpkgnum,
256         'discountnum' => $discountnum,
257         'setuprecur'  => $x,
258         #for the create a new discount case
259         'amount'      => $self->get("${x}_discountnum_amount"),
260         'percent'     => $self->get("${x}_discountnum_percent"),
261         'months'      => $self->get("${x}_discountnum_months"),
262       } );
263       if ( $x eq 'setup' ) {
264         $cust_pkg_discount->setup('Y');
265         $cust_pkg_discount->months('');
266       } 
267       my $error = $cust_pkg_discount->insert;
268       return $error if $error;
269     } 
270   } 
271 }
272
273 sub _item_discount {
274   my $self = shift;
275   my %options = @_;
276   my $setuprecur = $options{'setuprecur'};
277   # a little different from cust_bill_pkg::_item_discount, in that this one
278   # is asked specifically whether to show setup or recur discounts (because
279   # on the quotation they're separate sections entirely)
280
281   my @pkg_discounts = grep { $_->setuprecur eq $setuprecur }
282                         $self->pkg_discount;
283   return if @pkg_discounts == 0;
284   
285   my @ext;
286   my $d = {
287     _is_discount    => 1,
288     description     => $self->mt('Discount'),
289     amount          => 0,
290     ext_description => \@ext,
291     # maybe should show quantity/unit discount?
292   };
293   foreach my $pkg_discount (@pkg_discounts) {
294     push @ext, $pkg_discount->description;
295     my $amount = $pkg_discount->get('amount');
296     $d->{amount} -= $amount;
297   }
298   $d->{amount} = sprintf('%.2f', $d->{amount} * $self->quantity);
299   
300   return $d;
301 }
302
303 sub setup {
304   my $self = shift;
305   return '0.00' if $self->waive_setup eq 'Y';;
306   my $discount_amount = sum(0, map { $_->amount }
307                                grep { $_->setuprecur eq 'setup' }
308                                $self->pkg_discount
309                            );
310   ($self->unitsetup - $discount_amount) * ($self->quantity || 1);
311
312 }
313
314 sub setup_tax {
315   my $self = shift;
316   sum(0, map { $_->setup_amount } $self->quotation_pkg_tax);
317 }
318
319 sub recur {
320   my $self = shift;
321   my $discount_amount = sum(0, map { $_->amount }
322                                grep { $_->setuprecur eq 'recur' }
323                                $self->pkg_discount
324                            );
325   ($self->unitrecur - $discount_amount) * ($self->quantity || 1);
326
327 }
328
329 sub recur_tax {
330   my $self = shift;
331   sum(0, map { $_->recur_amount } $self->quotation_pkg_tax);
332 }
333
334 =item part_pkg_currency_option OPTIONNAME
335
336 Returns a two item list consisting of the currency of this quotation's customer
337 or prospect, if any, and a value for the provided option.  If the customer or
338 prospect has a currency, the value is the option value the given name and the
339 currency (see L<FS::part_pkg_currency>).  Otherwise, if the customer or
340 prospect has no currency, is the regular option value for the given name (see
341 L<FS::part_pkg_option>).
342
343 =cut
344
345 #false laziness w/cust_pkg->part_pkg_currency_option
346 sub part_pkg_currency_option {
347   my( $self, $optionname ) = @_;
348   my $part_pkg = $self->part_pkg;
349   my $prospect_or_customer = $self->cust_main || $self->prospect_main;
350   if ( my $currency = $prospect_or_customer->currency ) {
351     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
352   } else {
353     ('', $part_pkg->option($optionname) );
354   }
355 }
356
357 =item delete_details
358
359 Deletes all quotation_pkgs_details associated with this pkg (see L<FS::quotation_pkg_detail>).
360
361 =cut
362
363 sub delete_details {
364   my $self = shift;
365
366   my $oldAutoCommit = $FS::UID::AutoCommit;
367   local $FS::UID::AutoCommit = 0;
368   my $dbh = dbh;
369
370   foreach my $detail ( qsearch('quotation_pkg_detail',{ 'quotationpkgnum' => $self->quotationpkgnum }) ) {
371     my $error = $detail->delete;
372     if ( $error ) {
373       $dbh->rollback if $oldAutoCommit;
374       return "error removing old detail: $error";
375     }
376   }
377
378   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
379   '';
380
381 }
382
383 =item set_details PARAM
384
385 Sets new quotation details for this package (see L<FS::quotation_pkg_detail>),
386 removing existing details.
387
388 Recognizes the following parameters:
389
390 details - arrayref of strings, one for each new detail
391
392 copy_on_order - if true, sets copy_on_order flag on new details
393
394 If there is an error, returns the error, otherwise returns false.
395
396 =cut
397
398 sub set_details {
399   my $self = shift;
400   my %opt = @_;
401
402   $opt{'details'} ||= [];
403   my @details = @{$opt{'details'}};
404
405   my $oldAutoCommit = $FS::UID::AutoCommit;
406   local $FS::UID::AutoCommit = 0;
407   my $dbh = dbh;
408
409   my $error = $self->delete_details;
410   if ( $error ) {
411     $dbh->rollback if $oldAutoCommit;
412     return $error;
413   }
414
415   foreach my $detail ( @details ) {
416     my $quotation_pkg_detail = new FS::quotation_pkg_detail {
417       'quotationpkgnum' => $self->quotationpkgnum,
418       'detail' => $detail,
419       'copy_on_order' => $opt{'copy_on_order'} ? 'Y' : '',
420     };
421     $error = $quotation_pkg_detail->insert;
422     if ( $error ) {
423       $dbh->rollback if $oldAutoCommit;
424       return "error adding new detail: $error";
425     }
426   }
427
428   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
429   '';
430
431 }
432
433 sub details_header {
434   return ();
435 }
436
437 =item cust_bill_pkg_display [ type => TYPE ]
438
439 =cut
440
441 sub cust_bill_pkg_display {
442   my ( $self, %opt ) = @_;
443
444   my $type = $opt{type} if exists $opt{type};
445   return () if $type eq 'U'; #quotations don't have usage
446
447   if ( $self->get('display') ) {
448     return ( grep { defined($type) ? ($type eq $_->type) : 1 }
449                @{ $self->get('display') }
450            );
451   } else {
452
453     #??
454     my $setup = $self->new($self->hashref);
455     $setup->{'_NO_RECUR_KLUDGE'} = 1;
456     $setup->{'type'} = 'S';
457     my $recur = $self->new($self->hashref);
458     $recur->{'_NO_SETUP_KLUDGE'} = 1;
459     $recur->{'type'} = 'R';
460
461     if ( $type eq 'S' ) {
462       return ($setup);
463     } elsif ( $type eq 'R' ) {
464       return ($recur);
465     } else {
466       #return ($setup, $recur);
467       return ($self);
468     }
469
470   }
471
472 }
473
474 =item cust_main
475
476 Returns the customer (L<FS::cust_main> object).
477
478 =cut
479
480 sub cust_main {
481   my $self = shift;
482   my $quotation = $self->quotation or return '';
483   $quotation->cust_main;
484 }
485
486 =item prospect_main
487
488 Returns the prospect (L<FS::prospect_main> object).
489
490 =cut
491
492 sub prospect_main {
493   my $self = shift;
494   my $quotation = $self->quotation or return '';
495   $quotation->prospect_main;
496 }
497
498 sub tax_locationnum {
499   my $self = shift;
500   $self->locationnum;
501 }
502
503 sub _upgrade_data {
504   my $class = shift;
505   my @quotation_pkg_without_location =
506     qsearch( 'quotation_pkg', { locationnum => '' } );
507   if (@quotation_pkg_without_location) {
508     warn "setting default location on quotation_pkg records\n";
509     foreach my $quotation_pkg (@quotation_pkg_without_location) {
510       # check() will fix this
511       my $error = $quotation_pkg->replace;
512       if ($error) {
513         die "quotation #".$quotation_pkg->quotationnum.": $error\n";
514       }
515     }
516   }
517   '';
518 }
519
520 =back
521
522 =head1 BUGS
523
524 Doesn't support fees, or add-on packages.
525
526 =head1 SEE ALSO
527
528 L<FS::Record>, schema.html from the base documentation.
529
530 =cut
531
532 1;
533