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