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