959c4622ee35f900c836634510c65ae0a6e6406a
[freeside.git] / FS / FS / quotation_pkg.pm
1 package FS::quotation_pkg;
2
3 use strict;
4 use base qw( FS::TemplateItem_Mixin FS::Record );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::part_pkg;
7 use FS::cust_location;
8 use FS::quotation;
9 use FS::quotation_pkg_discount; #so its loaded when TemplateItem_Mixin needs it
10 use FS::quotation_pkg_detail;
11 use List::Util qw(sum);
12
13 =head1 NAME
14
15 FS::quotation_pkg - Object methods for quotation_pkg records
16
17 =head1 SYNOPSIS
18
19   use FS::quotation_pkg;
20
21   $record = new FS::quotation_pkg \%hash;
22   $record = new FS::quotation_pkg { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::quotation_pkg object represents a quotation package.
35 FS::quotation_pkg inherits from FS::Record.  The following fields are currently
36 supported:
37
38 =over 4
39
40 =item quotationpkgnum
41
42 primary key
43
44 =item pkgpart
45
46 pkgpart (L<FS::part_pkg>) of the package
47
48 =item locationnum
49
50 locationnum (L<FS::cust_location>) where the package will be in service
51
52 =item start_date
53
54 expected start date for the package, as a timestamp
55
56 =item contract_end
57
58 contract end date
59
60 =item quantity
61
62 quantity
63
64 =item waive_setup
65
66 'Y' to waive the setup fee
67
68 =item unitsetup
69
70 The amount per package that will be charged in setup/one-time fees.
71
72 =item unitrecur
73
74 The amount per package that will be charged per billing cycle.
75
76 =back
77
78 =head1 METHODS
79
80 =over 4
81
82 =item new HASHREF
83
84 Creates a new quotation package.  To add the quotation package to the database,
85 see L<"insert">.
86
87 Note that this stores the hash reference, not a distinct copy of the hash it
88 points to.  You can ask the object for a copy with the I<hash> method.
89
90 =cut
91
92 sub table { 'quotation_pkg'; }
93
94 sub display_table         { 'quotation_pkg'; }
95
96 #forget it, just overriding cust_bill_pkg_display entirely
97 #sub display_table_orderby { 'quotationpkgnum'; } # something else?
98 #                                                 #  (for invoice display order)
99
100 sub discount_table        { 'quotation_pkg_discount'; }
101 sub detail_table          { 'quotation_pkg_detail'; }
102
103 =item insert
104
105 Adds this record to the database.  If there is an error, returns the error,
106 otherwise returns false.
107
108 =cut
109
110 sub insert {
111   my ($self, %options) = @_;
112
113   my $dbh = dbh;
114   my $oldAutoCommit = $FS::UID::AutoCommit;
115   local $FS::UID::AutoCommit = 0;
116
117   my $error = $self->SUPER::insert;
118
119   if ( !$error and $self->discountnum ) {
120     $error = $self->insert_discount;
121     $error .= ' (setting discount)' if $error;
122   }
123
124   # update $self and any discounts with their amounts
125   if ( !$error ) {
126     $error = $self->estimate;
127     $error .= ' (calculating charges)' if $error;
128   }
129
130   if ( $error ) {
131     $dbh->rollback if $oldAutoCommit;
132     return $error;
133   } else {
134     $dbh->commit if $oldAutoCommit;
135     return '';
136   }
137 }
138
139 =item delete
140
141 Delete this record from the database.
142
143 =cut
144
145 sub delete {
146   my $self = shift;
147
148   my $dbh = dbh;
149   my $oldAutoCommit = $FS::UID::AutoCommit;
150   local $FS::UID::AutoCommit = 0;
151
152   my $error = $self->delete_details;
153   if ( $error ) {
154     $dbh->rollback if $oldAutoCommit;
155     return $error;
156   }
157
158   foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) {
159     $error = $_->delete;
160     if ( $error ) {
161       $dbh->rollback if $oldAutoCommit;
162       return $error . ' (deleting discount)';
163     }
164   }
165
166   $error = $self->SUPER::delete;
167   if ( $error ) {
168     $dbh->rollback if $oldAutoCommit;
169     return $error;
170   } else {
171     $dbh->commit if $oldAutoCommit;
172     return '';
173   }
174   
175 }
176
177 =item replace OLD_RECORD
178
179 Replaces the OLD_RECORD with this one in the database.  If there is an error,
180 returns the error, otherwise returns false.
181
182 =item check
183
184 Checks all fields to make sure this is a valid quotation package.  If there is
185 an error, returns the error, otherwise returns false.  Called by the insert
186 and replace methods.
187
188 =cut
189
190 sub check {
191   my $self = shift;
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('waive_setup', [ '', 'Y'] )
204   ;
205
206   if ($self->locationnum eq '') {
207     # use the customer default
208     my $quotation = $self->quotation;
209     if ($quotation->custnum) {
210       $self->set('locationnum', $quotation->cust_main->ship_locationnum);
211     } elsif ($quotation->prospectnum) {
212       # use the first non-disabled location for that prospect
213       my $cust_location = qsearchs('cust_location',
214         { prospectnum => $quotation->prospectnum,
215           disabled => '' });
216       $self->set('locationnum', $cust_location->locationnum) if $cust_location;
217     } # else the quotation is invalid
218   }
219
220   return $error if $error;
221
222   $self->SUPER::check;
223 }
224
225 sub part_pkg {
226   my $self = shift;
227   qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart } );
228 }
229
230 sub desc {
231   my $self = shift;
232   $self->part_pkg->pkg;
233 }
234
235 =item estimate
236
237 Update the quotation_pkg record with the estimated setup and recurring 
238 charges for the package. Returns nothing on success, or an error message
239 on failure.
240
241 =cut
242
243 sub estimate {
244   my $self = shift;
245   my $part_pkg = $self->part_pkg;
246   my $quantity = $self->quantity || 1;
247   my ($unitsetup, $unitrecur);
248   # calculate base fees
249   if ( $self->waive_setup eq 'Y' || $self->{'_NO_SETUP_KLUDGE'} ) {
250     $unitsetup = '0.00';
251   } else {
252     $unitsetup = $part_pkg->option('setup_fee',1) || '0.00'; # XXX 3.x only
253   }
254   if ( $self->{'_NO_RECUR_KLUDGE'} ) {
255     $unitrecur = '0.00';
256   } else {
257     $unitrecur = $part_pkg->base_recur;
258   }
259
260   #XXX add-on packages
261
262   $self->set('unitsetup', $unitsetup);
263   $self->set('unitrecur', $unitrecur);
264   my $error = $self->replace;
265   return $error if $error;
266
267   # semi-duplicates calc_discount
268   my $setup_discount = 0;
269   my $recur_discount = 0;
270
271   my %setup_discounts; # quotationpkgdiscountnum => amount
272   my %recur_discounts; # quotationpkgdiscountnum => amount
273
274   # XXX the order of applying discounts is ill-defined, which matters
275   # if there are percentage and amount discounts on the same package.
276   #
277   # but right now there can only be one discount on any package, so 
278   # it doesn't matter
279   foreach my $pkg_discount ($self->quotation_pkg_discount) {
280
281     my $discount = $pkg_discount->discount;
282     my $this_setup_discount = 0;
283     my $this_recur_discount = 0;
284
285     if ( $discount->percent > 0 ) {
286
287       if ( $discount->setup ) {
288         $this_setup_discount = ($discount->percent * $unitsetup / 100);
289       }
290       $this_recur_discount = ($discount->percent * $unitrecur / 100);
291
292     } elsif ( $discount->amount > 0 ) {
293
294       my $discount_left = $discount->amount;
295       if ( $discount->setup ) {
296         if ( $discount_left > $unitsetup - $setup_discount ) {
297           # then discount the setup to zero
298           $discount_left -= $unitsetup - $setup_discount;
299           $this_setup_discount = $unitsetup - $setup_discount;
300         } else {
301           # not enough discount to fully cover the setup
302           $this_setup_discount = $discount_left;
303           $discount_left = 0;
304         }
305       }
306       # same logic for recur
307       if ( $discount_left > $unitrecur - $recur_discount ) {
308         $this_recur_discount = $unitrecur - $recur_discount;
309       } else {
310         $this_recur_discount = $discount_left;
311       }
312
313     }
314
315     # increment the total discountage
316     $setup_discount += $this_setup_discount;
317     $recur_discount += $this_recur_discount;
318     # and update the pkg_discount object
319     $pkg_discount->set('setup_amount', sprintf('%.2f', $setup_discount));
320     $pkg_discount->set('recur_amount', sprintf('%.2f', $recur_discount));
321     my $error = $pkg_discount->replace;
322     return $error if $error;
323   }
324
325   '';
326 }
327
328 =item insert_discount
329
330 Associates this package with a discount (see L<FS::cust_pkg_discount>,
331 possibly inserting a new discount on the fly (see L<FS::discount>). Properties
332 of the discount will be taken from this object.
333
334 =cut
335
336 sub insert_discount {
337   #my ($self, %options) = @_;
338   my $self = shift;
339
340   my $quotation_pkg_discount = FS::quotation_pkg_discount->new( {
341     'quotationpkgnum' => $self->quotationpkgnum,
342     'discountnum'     => $self->discountnum,
343     #for the create a new discount case
344     '_type'           => $self->discountnum__type,
345     'amount'      => $self->discountnum_amount,
346     'percent'     => $self->discountnum_percent,
347     'months'      => $self->discountnum_months,
348     'setup'       => $self->discountnum_setup,
349   } );
350
351   $quotation_pkg_discount->insert;
352 }
353
354 sub _item_discount {
355   my $self = shift;
356   my @pkg_discounts = $self->pkg_discount;
357   return if @pkg_discounts == 0;
358   
359   my @ext;
360   my $d = {
361     _is_discount    => 1,
362     description     => $self->mt('Discount'),
363     setup_amount    => 0,
364     recur_amount    => 0,
365     amount          => 0,
366     ext_description => \@ext,
367     # maybe should show quantity/unit discount?
368   };
369   foreach my $pkg_discount (@pkg_discounts) {
370     push @ext, $pkg_discount->description;
371     $d->{setup_amount} -= $pkg_discount->setup_amount;
372     $d->{recur_amount} -= $pkg_discount->recur_amount;
373   }
374   $d->{setup_amount} *= $self->quantity || 1;
375   $d->{recur_amount} *= $self->quantity || 1;
376   $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
377   
378   return $d;
379 }
380
381 sub setup {
382   my $self = shift;
383   ($self->unitsetup - sum(0, map { $_->setup_amount } $self->pkg_discount))
384     * ($self->quantity || 1);
385 }
386
387 sub setup_show_zero {
388   my $self = shift;
389   return $self->part_pkg->setup_show_zero;
390 }
391
392 sub recur {
393   my $self = shift;
394   ($self->unitrecur - sum(0, map { $_->recur_amount } $self->pkg_discount))
395     * ($self->quantity || 1);
396 }
397
398 sub recur_show_zero {
399   my $self = shift;
400   return $self->part_pkg->recur_show_zero;
401 }
402
403 =item delete_details
404
405 Deletes all quotation_pkgs_details associated with this pkg (see L<FS::quotation_pkg_detail>).
406
407 =cut
408
409 sub delete_details {
410   my $self = shift;
411
412   my $oldAutoCommit = $FS::UID::AutoCommit;
413   local $FS::UID::AutoCommit = 0;
414   my $dbh = dbh;
415
416   foreach my $detail ( qsearch('quotation_pkg_detail',{ 'quotationpkgnum' => $self->quotationpkgnum }) ) {
417     my $error = $detail->delete;
418     if ( $error ) {
419       $dbh->rollback if $oldAutoCommit;
420       return "error removing old detail: $error";
421     }
422   }
423
424   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
425   '';
426
427 }
428
429 =item set_details PARAM
430
431 Sets new quotation details for this package (see L<FS::quotation_pkg_detail>),
432 removing existing details.
433
434 Recognizes the following parameters:
435
436 details - arrayref of strings, one for each new detail
437
438 copy_on_order - if true, sets copy_on_order flag on new details
439
440 If there is an error, returns the error, otherwise returns false.
441
442 =cut
443
444 sub set_details {
445   my $self = shift;
446   my %opt = @_;
447
448   $opt{'details'} ||= [];
449   my @details = @{$opt{'details'}};
450
451   my $oldAutoCommit = $FS::UID::AutoCommit;
452   local $FS::UID::AutoCommit = 0;
453   my $dbh = dbh;
454
455   my $error = $self->delete_details;
456   if ( $error ) {
457     $dbh->rollback if $oldAutoCommit;
458     return $error;
459   }
460
461   foreach my $detail ( @details ) {
462     my $quotation_pkg_detail = new FS::quotation_pkg_detail {
463       'quotationpkgnum' => $self->quotationpkgnum,
464       'detail' => $detail,
465       'copy_on_order' => $opt{'copy_on_order'} ? 'Y' : '',
466     };
467     $error = $quotation_pkg_detail->insert;
468     if ( $error ) {
469       $dbh->rollback if $oldAutoCommit;
470       return "error adding new detail: $error";
471     }
472   }
473
474   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
475   '';
476
477 }
478
479 sub details_header {
480   return ();
481 }
482
483 =item cust_bill_pkg_display [ type => TYPE ]
484
485 =cut
486
487 sub cust_bill_pkg_display {
488   my ( $self, %opt ) = @_;
489
490   my $type = $opt{type} if exists $opt{type};
491   return () if $type eq 'U'; #quotations don't have usage
492
493   if ( $self->get('display') ) {
494     return ( grep { defined($type) ? ($type eq $_->type) : 1 }
495                @{ $self->get('display') }
496            );
497   } else {
498
499     #??
500     my $setup = $self->new($self->hashref);
501     $setup->{'_NO_RECUR_KLUDGE'} = 1;
502     $setup->{'type'} = 'S';
503     my $recur = $self->new($self->hashref);
504     $recur->{'_NO_SETUP_KLUDGE'} = 1;
505     $recur->{'type'} = 'R';
506
507     if ( $type eq 'S' ) {
508       return ($setup);
509     } elsif ( $type eq 'R' ) {
510       return ($recur);
511     } else {
512       #return ($setup, $recur);
513       return ($self);
514     }
515
516   }
517
518 }
519
520 =item cust_main
521
522 Returns the customer (L<FS::cust_main> object).
523
524 =cut
525
526 sub cust_main {
527   my $self = shift;
528   my $quotation = FS::quotation->by_key($self->quotationnum) or return '';
529   $quotation->cust_main;
530 }
531
532 sub tax_locationnum {
533   my $self = shift;
534   $self->locationnum;
535 }
536
537 #stub for 3.x
538
539 sub quotation {
540   my $self = shift;
541   FS::quotation->by_key($self->quotationnum);
542 }
543
544 sub quotation_pkg_detail {
545   my $self = shift;
546   sort { $a->detailnum <=> $b->detailnum }
547     qsearch('quotation_pkg_detail', { quotationpkgnum => $self->quotationpkgnum });
548 }
549
550 sub quotation_pkg_discount {
551   my $self = shift;
552   qsearch('quotation_pkg_discount', { quotationpkgnum => $self->quotationpkgnum });
553 }
554
555 sub quotation_pkg_tax {
556   my $self = shift;
557   qsearch('quotation_pkg_tax', { quotationpkgnum => $self->quotationpkgnum });
558 }
559
560 sub cust_location {
561   my $self = shift;
562   $self->locationnum ? qsearchs('cust_location', { locationnum => $self->locationnum }) : '';
563 }
564
565
566 sub _upgrade_data {
567   my $class = shift;
568   my @quotation_pkg_without_location =
569     qsearch( 'quotation_pkg', { locationnum => '' } );
570   if (@quotation_pkg_without_location) {
571     warn "setting default location on quotation_pkg records\n";
572     foreach my $quotation_pkg (@quotation_pkg_without_location) {
573       # check() will fix this
574       my $error = $quotation_pkg->replace;
575       if ($error) {
576         die "quotation #".$quotation_pkg->quotationnum.": $error\n";
577       }
578     }
579   }
580   '';
581 }
582
583 =back
584
585 =head1 BUGS
586
587 Doesn't support fees, or add-on packages.
588
589 =head1 SEE ALSO
590
591 L<FS::Record>, schema.html from the base documentation.
592
593 =cut
594
595 1;
596