4.x+ self-service API: list and remove cards on file, RT#38919
[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
105 # detail table uses non-quotation fieldnames, see billpkgnum below
106 sub detail_table          { 'quotation_pkg_detail'; }
107
108 =item billpkgnum
109
110 Sets/returns quotationpkgnum, for ease of integration with TemplateItem_Mixin::details
111
112 =cut
113
114 sub billpkgnum {
115   my $self = shift;
116   $self->quotationpkgnum(@_);
117 }
118
119 =item insert
120
121 Adds this record to the database.  If there is an error, returns the error,
122 otherwise returns false.
123
124 =cut
125
126 sub insert {
127   my ($self, %options) = @_;
128
129   my $dbh = dbh;
130   my $oldAutoCommit = $FS::UID::AutoCommit;
131   local $FS::UID::AutoCommit = 0;
132
133   my $error = $self->SUPER::insert;
134
135   if ( !$error and ($self->setup_discountnum || $self->recur_discountnum) ) {
136       #warn "inserting discount\n";
137     $error = $self->insert_discount;
138     $error .= ' (setting discount)' if $error;
139   }
140
141   if ( $error ) {
142     $dbh->rollback if $oldAutoCommit;
143     return $error;
144   } else {
145     $dbh->commit if $oldAutoCommit;
146     return '';
147   }
148 }
149
150 =item delete
151
152 Delete this record from the database.
153
154 =cut
155
156 sub delete {
157   my $self = shift;
158
159   my $dbh = dbh;
160   my $oldAutoCommit = $FS::UID::AutoCommit;
161   local $FS::UID::AutoCommit = 0;
162
163   my $error = $self->delete_details;
164   if ( $error ) {
165     $dbh->rollback if $oldAutoCommit;
166     return $error;
167   }
168
169   foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) {
170     $error = $_->delete;
171     if ( $error ) {
172       $dbh->rollback if $oldAutoCommit;
173       return $error . ' (deleting discount)';
174     }
175   }
176
177   $error = $self->SUPER::delete;
178   if ( $error ) {
179     $dbh->rollback if $oldAutoCommit;
180     return $error;
181   } else {
182     $dbh->commit if $oldAutoCommit;
183   }
184   
185   $self->quotation->estimate;
186 }
187
188 =item replace OLD_RECORD
189
190 Replaces the OLD_RECORD with this one in the database.  If there is an error,
191 returns the error, otherwise returns false.
192
193 =item check
194
195 Checks all fields to make sure this is a valid quotation package.  If there is
196 an error, returns the error, otherwise returns false.  Called by the insert
197 and replace methods.
198
199 =cut
200
201 sub check {
202   my $self = shift;
203
204   my @freqs = ('', keys (%{ FS::Misc->pkg_freqs }));
205
206   my $error = 
207     $self->ut_numbern('quotationpkgnum')
208     || $self->ut_foreign_key(  'quotationnum', 'quotation',    'quotationnum' )
209     || $self->ut_foreign_key(  'pkgpart',      'part_pkg',     'pkgpart'      )
210     || $self->ut_foreign_keyn( 'locationnum', 'cust_location', 'locationnum'  )
211     || $self->ut_numbern('start_date')
212     || $self->ut_numbern('contract_end')
213     || $self->ut_numbern('quantity')
214     || $self->ut_moneyn('unitsetup')
215     || $self->ut_moneyn('unitrecur')
216     || $self->ut_enum('freq', \@freqs)
217     || $self->ut_enum('waive_setup', [ '', 'Y'] )
218   ;
219
220   if ($self->locationnum eq '') {
221     # use the customer default
222     my $quotation = $self->quotation;
223     if ($quotation->custnum) {
224       $self->set('locationnum', $quotation->cust_main->ship_locationnum);
225     } elsif ($quotation->prospectnum) {
226       # use the first non-disabled location for that prospect
227       my $cust_location = qsearchs('cust_location',
228         { prospectnum => $quotation->prospectnum,
229           disabled => '' });
230       $self->set('locationnum', $cust_location->locationnum) if $cust_location;
231     } # else the quotation is invalid
232   }
233
234   return $error if $error;
235
236   $self->SUPER::check;
237 }
238
239 #it looks redundant with a v4.x+ auto-generated method, but need to override
240 # FS::TemplateItem_Mixin's version
241 sub part_pkg {
242   my $self = shift;
243   qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart } );
244 }
245
246 sub desc {
247   my $self = shift;
248   $self->part_pkg->pkg;
249 }
250
251 =cut
252
253 =item insert_discount
254
255 Associates this package with a discount (see L<FS::cust_pkg_discount>,
256 possibly inserting a new discount on the fly (see L<FS::discount>). Properties
257 of the discount will be taken from this object.
258
259 =cut
260
261 sub insert_discount {
262   #my ($self, %options) = @_;
263   my $self = shift;
264
265   foreach my $x (qw(setup recur)) {
266     if ( my $discountnum = $self->get("${x}_discountnum") ) {
267       my $cust_pkg_discount = FS::quotation_pkg_discount->new( { 
268         'quotationpkgnum' => $self->quotationpkgnum,
269         'discountnum' => $discountnum,
270         'setuprecur'  => $x,
271         #for the create a new discount case
272         'amount'      => $self->get("${x}_discountnum_amount"),
273         'percent'     => $self->get("${x}_discountnum_percent"),
274         'months'      => $self->get("${x}_discountnum_months"),
275       } );
276       if ( $x eq 'setup' ) {
277         $cust_pkg_discount->setup('Y');
278         $cust_pkg_discount->months('');
279       } 
280       my $error = $cust_pkg_discount->insert;
281       return $error if $error;
282     } 
283   } 
284 }
285
286 sub _item_discount {
287   my $self = shift;
288   my %options = @_;
289   my $setuprecur = $options{'setuprecur'};
290   # a little different from cust_bill_pkg::_item_discount, in that this one
291   # is asked specifically whether to show setup or recur discounts (because
292   # on the quotation they're separate sections entirely)
293
294   my @pkg_discounts = grep { $_->setuprecur eq $setuprecur }
295                         $self->pkg_discount;
296   return if @pkg_discounts == 0;
297   
298   my @ext;
299   my $d = {
300     _is_discount    => 1,
301     description     => $self->mt('Discount'),
302     amount          => 0,
303     ext_description => \@ext,
304     # maybe should show quantity/unit discount?
305   };
306   foreach my $pkg_discount (@pkg_discounts) {
307     push @ext, $pkg_discount->description;
308     my $amount = $pkg_discount->get('amount');
309     $d->{amount} -= $amount;
310   }
311   $d->{amount} = sprintf('%.2f', $d->{amount} * $self->quantity);
312   
313   return $d;
314 }
315
316 sub setup {
317   my $self = shift;
318   return '0.00' if $self->waive_setup eq 'Y';;
319   my $discount_amount = sum(0, map { $_->amount }
320                                grep { $_->setuprecur eq 'setup' }
321                                $self->pkg_discount
322                            );
323   ($self->unitsetup - $discount_amount) * ($self->quantity || 1);
324
325 }
326
327 sub setup_tax {
328   my $self = shift;
329   sum(0, map { $_->setup_amount } $self->quotation_pkg_tax);
330 }
331
332 sub recur {
333   my $self = shift;
334   my $discount_amount = sum(0, map { $_->amount }
335                                grep { $_->setuprecur eq 'recur' }
336                                $self->pkg_discount
337                            );
338   ($self->unitrecur - $discount_amount) * ($self->quantity || 1);
339
340 }
341
342 sub recur_tax {
343   my $self = shift;
344   sum(0, map { $_->recur_amount } $self->quotation_pkg_tax);
345 }
346
347 =item part_pkg_currency_option OPTIONNAME
348
349 Returns a two item list consisting of the currency of this quotation's customer
350 or prospect, if any, and a value for the provided option.  If the customer or
351 prospect has a currency, the value is the option value the given name and the
352 currency (see L<FS::part_pkg_currency>).  Otherwise, if the customer or
353 prospect has no currency, is the regular option value for the given name (see
354 L<FS::part_pkg_option>).
355
356 =cut
357
358 #false laziness w/cust_pkg->part_pkg_currency_option
359 sub part_pkg_currency_option {
360   my( $self, $optionname ) = @_;
361   my $part_pkg = $self->part_pkg;
362   my $prospect_or_customer = $self->cust_main || $self->prospect_main;
363   if ( my $currency = $prospect_or_customer->currency ) {
364     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
365   } else {
366     ('', $part_pkg->option($optionname) );
367   }
368 }
369
370 =item delete_details
371
372 Deletes all quotation_pkgs_details associated with this pkg (see L<FS::quotation_pkg_detail>).
373
374 =cut
375
376 sub delete_details {
377   my $self = shift;
378
379   my $oldAutoCommit = $FS::UID::AutoCommit;
380   local $FS::UID::AutoCommit = 0;
381   my $dbh = dbh;
382
383   foreach my $detail ( qsearch('quotation_pkg_detail',{ 'billpkgnum' => $self->quotationpkgnum }) ) {
384     my $error = $detail->delete;
385     if ( $error ) {
386       $dbh->rollback if $oldAutoCommit;
387       return "error removing old detail: $error";
388     }
389   }
390
391   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
392   '';
393
394 }
395
396 =item set_details [ DETAIL, DETAIL, ... ]
397
398 Sets quotation details for this package (see L<FS::quotation_pkg_detail>).
399
400 If there is an error, returns the error, otherwise returns false.
401
402 =cut
403
404 sub set_details {
405   my( $self, @details ) = @_;
406
407   my $oldAutoCommit = $FS::UID::AutoCommit;
408   local $FS::UID::AutoCommit = 0;
409   my $dbh = dbh;
410
411   my $error = $self->delete_details;
412   if ( $error ) {
413     $dbh->rollback if $oldAutoCommit;
414     return $error;
415   }
416
417   foreach my $detail ( @details ) {
418     my $quotation_pkg_detail = new FS::quotation_pkg_detail {
419       'billpkgnum' => $self->quotationpkgnum,
420       'detail'     => $detail,
421     };
422     $error = $quotation_pkg_detail->insert;
423     if ( $error ) {
424       $dbh->rollback if $oldAutoCommit;
425       return "error adding new detail: $error";
426     }
427   }
428
429   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
430   '';
431
432 }
433
434 =item cust_bill_pkg_display [ type => TYPE ]
435
436 =cut
437
438 sub cust_bill_pkg_display {
439   my ( $self, %opt ) = @_;
440
441   my $type = $opt{type} if exists $opt{type};
442   return () if $type eq 'U'; #quotations don't have usage
443
444   if ( $self->get('display') ) {
445     return ( grep { defined($type) ? ($type eq $_->type) : 1 }
446                @{ $self->get('display') }
447            );
448   } else {
449
450     #??
451     my $setup = $self->new($self->hashref);
452     $setup->{'_NO_RECUR_KLUDGE'} = 1;
453     $setup->{'type'} = 'S';
454     my $recur = $self->new($self->hashref);
455     $recur->{'_NO_SETUP_KLUDGE'} = 1;
456     $recur->{'type'} = 'R';
457
458     if ( $type eq 'S' ) {
459       return ($setup);
460     } elsif ( $type eq 'R' ) {
461       return ($recur);
462     } else {
463       #return ($setup, $recur);
464       return ($self);
465     }
466
467   }
468
469 }
470
471 =item cust_main
472
473 Returns the customer (L<FS::cust_main> object).
474
475 =cut
476
477 sub cust_main {
478   my $self = shift;
479   my $quotation = $self->quotation or return '';
480   $quotation->cust_main;
481 }
482
483 =item prospect_main
484
485 Returns the prospect (L<FS::prospect_main> object).
486
487 =cut
488
489 sub prospect_main {
490   my $self = shift;
491   my $quotation = $self->quotation or return '';
492   $quotation->prospect_main;
493 }
494
495 sub tax_locationnum {
496   my $self = shift;
497   $self->locationnum;
498 }
499
500 sub _upgrade_data {
501   my $class = shift;
502   my @quotation_pkg_without_location =
503     qsearch( 'quotation_pkg', { locationnum => '' } );
504   if (@quotation_pkg_without_location) {
505     warn "setting default location on quotation_pkg records\n";
506     foreach my $quotation_pkg (@quotation_pkg_without_location) {
507       # check() will fix this
508       my $error = $quotation_pkg->replace;
509       if ($error) {
510         die "quotation #".$quotation_pkg->quotationnum.": $error\n";
511       }
512     }
513   }
514   '';
515 }
516
517 =back
518
519 =head1 BUGS
520
521 Doesn't support fees, or add-on packages.
522
523 =head1 SEE ALSO
524
525 L<FS::Record>, schema.html from the base documentation.
526
527 =cut
528
529 1;
530