separate setup/recur quotation discounts, #14092
[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 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 =item freq
74
75 The length of the billing cycle. If zero it's a one-time charge; if any 
76 other number it's that many months; other values are in L<FS::Misc::pkg_freqs>.
77
78 =back
79
80 =head1 METHODS
81
82 =over 4
83
84 =item new HASHREF
85
86 Creates a new quotation package.  To add the quotation package to the database,
87 see L<"insert">.
88
89 Note that this stores the hash reference, not a distinct copy of the hash it
90 points to.  You can ask the object for a copy with the I<hash> method.
91
92 =cut
93
94 sub table { 'quotation_pkg'; }
95
96 sub display_table         { 'quotation_pkg'; }
97
98 #forget it, just overriding cust_bill_pkg_display entirely
99 #sub display_table_orderby { 'quotationpkgnum'; } # something else?
100 #                                                 #  (for invoice display order)
101
102 sub discount_table        { 'quotation_pkg_discount'; }
103
104 =item insert
105
106 Adds this record to the database.  If there is an error, returns the error,
107 otherwise returns false.
108
109 =cut
110
111 sub insert {
112   my ($self, %options) = @_;
113
114   my $dbh = dbh;
115   my $oldAutoCommit = $FS::UID::AutoCommit;
116   local $FS::UID::AutoCommit = 0;
117
118   my $error = $self->SUPER::insert;
119
120   if ( !$error and ($self->setup_discountnum || $self->recur_discountnum) ) {
121       #warn "inserting discount\n";
122     $error = $self->insert_discount;
123     $error .= ' (setting discount)' if $error;
124   }
125
126   if ( $error ) {
127     $dbh->rollback if $oldAutoCommit;
128     return $error;
129   } else {
130     $dbh->commit if $oldAutoCommit;
131     return '';
132   }
133 }
134
135 =item delete
136
137 Delete this record from the database.
138
139 =cut
140
141 sub delete {
142   my $self = shift;
143
144   my $dbh = dbh;
145   my $oldAutoCommit = $FS::UID::AutoCommit;
146   local $FS::UID::AutoCommit = 0;
147
148   foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) {
149     my $error = $_->delete;
150     if ( $error ) {
151       $dbh->rollback if $oldAutoCommit;
152       return $error . ' (deleting discount)';
153     }
154   }
155
156   my $error = $self->SUPER::delete;
157   if ( $error ) {
158     $dbh->rollback if $oldAutoCommit;
159     return $error;
160   } else {
161     $dbh->commit if $oldAutoCommit;
162   }
163   
164   $self->quotation->estimate;
165 }
166
167 =item replace OLD_RECORD
168
169 Replaces the OLD_RECORD with this one in the database.  If there is an error,
170 returns the error, otherwise returns false.
171
172 =item check
173
174 Checks all fields to make sure this is a valid quotation package.  If there is
175 an error, returns the error, otherwise returns false.  Called by the insert
176 and replace methods.
177
178 =cut
179
180 sub check {
181   my $self = shift;
182
183   my @freqs = ('', keys (%{ FS::Misc->pkg_freqs }));
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('freq', \@freqs)
196     || $self->ut_enum('waive_setup', [ '', 'Y'] )
197   ;
198
199   if ($self->locationnum eq '') {
200     # use the customer default
201     my $quotation = $self->quotation;
202     if ($quotation->custnum) {
203       $self->set('locationnum', $quotation->cust_main->ship_locationnum);
204     } elsif ($quotation->prospectnum) {
205       # use the first non-disabled location for that prospect
206       my $cust_location = qsearchs('cust_location',
207         { prospectnum => $quotation->prospectnum,
208           disabled => '' });
209       $self->set('locationnum', $cust_location->locationnum) if $cust_location;
210     } # else the quotation is invalid
211   }
212
213   return $error if $error;
214
215   $self->SUPER::check;
216 }
217
218 #it looks redundant with a v4.x+ auto-generated method, but need to override
219 # FS::TemplateItem_Mixin's version
220 sub part_pkg {
221   my $self = shift;
222   qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart } );
223 }
224
225 sub desc {
226   my $self = shift;
227   $self->part_pkg->pkg;
228 }
229
230 =cut
231
232 =item insert_discount
233
234 Associates this package with a discount (see L<FS::cust_pkg_discount>,
235 possibly inserting a new discount on the fly (see L<FS::discount>). Properties
236 of the discount will be taken from this object.
237
238 =cut
239
240 sub insert_discount {
241   #my ($self, %options) = @_;
242   my $self = shift;
243
244   foreach my $x (qw(setup recur)) {
245     if ( my $discountnum = $self->get("${x}_discountnum") ) {
246       my $cust_pkg_discount = FS::quotation_pkg_discount->new( { 
247         'quotationpkgnum' => $self->quotationpkgnum,
248         'discountnum' => $discountnum,
249         'setuprecur'  => $x,
250         #for the create a new discount case
251         'amount'      => $self->get("${x}_discountnum_amount"),
252         'percent'     => $self->get("${x}_discountnum_percent"),
253         'months'      => $self->get("${x}_discountnum_months"),
254       } );
255       if ( $x eq 'setup' ) {
256         $cust_pkg_discount->setup('Y');
257         $cust_pkg_discount->months('');
258       } 
259       my $error = $cust_pkg_discount->insert;
260       return $error if $error;
261     } 
262   } 
263 }
264
265 sub _item_discount {
266   my $self = shift;
267   my %options = @_;
268   my $setuprecur = $options{'setuprecur'};
269   # a little different from cust_bill_pkg::_item_discount, in that this one
270   # is asked specifically whether to show setup or recur discounts (because
271   # on the quotation they're separate sections entirely)
272
273   my @pkg_discounts = grep { $_->setuprecur eq $setuprecur }
274                         $self->pkg_discount;
275   return if @pkg_discounts == 0;
276   
277   my @ext;
278   my $d = {
279     _is_discount    => 1,
280     description     => $self->mt('Discount'),
281     amount          => 0,
282     ext_description => \@ext,
283     # maybe should show quantity/unit discount?
284   };
285   foreach my $pkg_discount (@pkg_discounts) {
286     push @ext, $pkg_discount->description;
287     my $amount = $pkg_discount->get('amount');
288     $d->{amount} -= $amount;
289   }
290   $d->{amount} = sprintf('%.2f', $d->{amount} * $self->quantity);
291   
292   return $d;
293 }
294
295 sub setup {
296   my $self = shift;
297   return '0.00' if $self->waive_setup eq 'Y';;
298   my $discount_amount = sum(0, map { $_->amount }
299                                grep { $_->setuprecur eq 'setup' }
300                                $self->pkg_discount
301                            );
302   ($self->unitsetup - $discount_amount) * ($self->quantity || 1);
303
304 }
305
306 sub setup_tax {
307   my $self = shift;
308   sum(0, map { $_->setup_amount } $self->quotation_pkg_tax);
309 }
310
311 sub recur {
312   my $self = shift;
313   my $discount_amount = sum(0, map { $_->amount }
314                                grep { $_->setuprecur eq 'recur' }
315                                $self->pkg_discount
316                            );
317   ($self->unitrecur - $discount_amount) * ($self->quantity || 1);
318
319 }
320
321 sub recur_tax {
322   my $self = shift;
323   sum(0, map { $_->recur_amount } $self->quotation_pkg_tax);
324 }
325
326 =item part_pkg_currency_option OPTIONNAME
327
328 Returns a two item list consisting of the currency of this quotation's customer
329 or prospect, if any, and a value for the provided option.  If the customer or
330 prospect has a currency, the value is the option value the given name and the
331 currency (see L<FS::part_pkg_currency>).  Otherwise, if the customer or
332 prospect has no currency, is the regular option value for the given name (see
333 L<FS::part_pkg_option>).
334
335 =cut
336
337 #false laziness w/cust_pkg->part_pkg_currency_option
338 sub part_pkg_currency_option {
339   my( $self, $optionname ) = @_;
340   my $part_pkg = $self->part_pkg;
341   my $prospect_or_customer = $self->cust_main || $self->prospect_main;
342   if ( my $currency = $prospect_or_customer->currency ) {
343     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
344   } else {
345     ('', $part_pkg->option($optionname) );
346   }
347 }
348
349
350 =item cust_bill_pkg_display [ type => TYPE ]
351
352 =cut
353
354 sub cust_bill_pkg_display {
355   my ( $self, %opt ) = @_;
356
357   my $type = $opt{type} if exists $opt{type};
358   return () if $type eq 'U'; #quotations don't have usage
359
360   if ( $self->get('display') ) {
361     return ( grep { defined($type) ? ($type eq $_->type) : 1 }
362                @{ $self->get('display') }
363            );
364   } else {
365
366     #??
367     my $setup = $self->new($self->hashref);
368     $setup->{'_NO_RECUR_KLUDGE'} = 1;
369     $setup->{'type'} = 'S';
370     my $recur = $self->new($self->hashref);
371     $recur->{'_NO_SETUP_KLUDGE'} = 1;
372     $recur->{'type'} = 'R';
373
374     if ( $type eq 'S' ) {
375       return ($setup);
376     } elsif ( $type eq 'R' ) {
377       return ($recur);
378     } else {
379       #return ($setup, $recur);
380       return ($self);
381     }
382
383   }
384
385 }
386
387 =item cust_main
388
389 Returns the customer (L<FS::cust_main> object).
390
391 =cut
392
393 sub cust_main {
394   my $self = shift;
395   my $quotation = $self->quotation or return '';
396   $quotation->cust_main;
397 }
398
399 =item prospect_main
400
401 Returns the prospect (L<FS::prospect_main> object).
402
403 =cut
404
405 sub prospect_main {
406   my $self = shift;
407   my $quotation = $self->quotation or return '';
408   $quotation->prospect_main;
409 }
410
411 sub tax_locationnum {
412   my $self = shift;
413   $self->locationnum;
414 }
415
416 sub _upgrade_data {
417   my $class = shift;
418   my @quotation_pkg_without_location =
419     qsearch( 'quotation_pkg', { locationnum => '' } );
420   if (@quotation_pkg_without_location) {
421     warn "setting default location on quotation_pkg records\n";
422     foreach my $quotation_pkg (@quotation_pkg_without_location) {
423       # check() will fix this
424       my $error = $quotation_pkg->replace;
425       if ($error) {
426         die "quotation #".$quotation_pkg->quotationnum.": $error\n";
427       }
428     }
429   }
430   '';
431 }
432
433 =back
434
435 =head1 BUGS
436
437 Doesn't support fees, or add-on packages.
438
439 =head1 SEE ALSO
440
441 L<FS::Record>, schema.html from the base documentation.
442
443 =cut
444
445 1;
446