18a065da18f15537e760c2e580e8c427112f40c3
[freeside.git] / FS / FS / part_pkg.pm
1 package FS::part_pkg;
2 use base qw( FS::m2m_Common FS::o2m_Common FS::option_Common );
3
4 use strict;
5 use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
6 use Carp qw(carp cluck confess);
7 use Scalar::Util qw( blessed );
8 use DateTime;
9 use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with DateTime
10 use Tie::IxHash;
11 use FS::Conf;
12 use FS::Record qw( qsearch qsearchs dbh dbdef );
13 use FS::Cursor; # for upgrade
14 use FS::pkg_svc;
15 use FS::part_svc;
16 use FS::cust_pkg;
17 use FS::agent_type;
18 use FS::type_pkgs;
19 use FS::part_pkg_option;
20 use FS::part_pkg_fcc_option;
21 use FS::pkg_class;
22 use FS::agent;
23 use FS::part_pkg_msgcat;
24 use FS::part_pkg_taxrate;
25 use FS::part_pkg_taxoverride;
26 use FS::part_pkg_taxproduct;
27 use FS::part_pkg_link;
28 use FS::part_pkg_discount;
29 use FS::part_pkg_usage;
30 use FS::part_pkg_vendor;
31
32 $DEBUG = 0;
33 $setup_hack = 0;
34 $skip_pkg_svc_hack = 0;
35
36 =head1 NAME
37
38 FS::part_pkg - Object methods for part_pkg objects
39
40 =head1 SYNOPSIS
41
42   use FS::part_pkg;
43
44   $record = new FS::part_pkg \%hash
45   $record = new FS::part_pkg { 'column' => 'value' };
46
47   $custom_record = $template_record->clone;
48
49   $error = $record->insert;
50
51   $error = $new_record->replace($old_record);
52
53   $error = $record->delete;
54
55   $error = $record->check;
56
57   @pkg_svc = $record->pkg_svc;
58
59   $svcnum = $record->svcpart;
60   $svcnum = $record->svcpart( 'svc_acct' );
61
62 =head1 DESCRIPTION
63
64 An FS::part_pkg object represents a package definition.  FS::part_pkg
65 inherits from FS::Record.  The following fields are currently supported:
66
67 =over 4
68
69 =item pkgpart - primary key (assigned automatically for new package definitions)
70
71 =item pkg - Text name of this package definition (customer-viewable)
72
73 =item comment - Text name of this package definition (non-customer-viewable)
74
75 =item classnum - Optional package class (see L<FS::pkg_class>)
76
77 =item promo_code - Promotional code
78
79 =item setup - Setup fee expression (deprecated)
80
81 =item freq - Frequency of recurring fee
82
83 =item recur - Recurring fee expression (deprecated)
84
85 =item setuptax - Setup fee tax exempt flag, empty or `Y'
86
87 =item recurtax - Recurring fee tax exempt flag, empty or `Y'
88
89 =item taxclass - Tax class 
90
91 =item plan - Price plan
92
93 =item plandata - Price plan data (deprecated - see L<FS::part_pkg_option> instead)
94
95 =item disabled - Disabled flag, empty or `Y'
96
97 =item custom - Custom flag, empty or `Y'
98
99 =item setup_cost - for cost tracking
100
101 =item recur_cost - for cost tracking
102
103 =item pay_weight - Weight (relative to credit_weight and other package definitions) that controls payment application to specific line items.
104
105 =item credit_weight - Weight (relative to other package definitions) that controls credit application to specific line items.
106
107 =item agentnum - Optional agentnum (see L<FS::agent>)
108
109 =item fcc_ds0s - Optional DS0 equivalency number for FCC form 477
110
111 =item fcc_voip_class - Which column of FCC form 477 part II.B this package 
112 belongs in.
113
114 =item successor - Foreign key for the part_pkg that replaced this record.
115 If this record is not obsolete, will be null.
116
117 =item family_pkgpart - Foreign key for the part_pkg that was the earliest
118 ancestor of this record.  If this record is not a successor to another 
119 part_pkg, will be equal to pkgpart.
120
121 =item delay_start - Number of days to delay package start, by default
122
123 =item start_on_hold - 'Y' to suspend this package immediately when it is 
124 ordered. The package will not start billing or have a setup fee charged 
125 until it is manually unsuspended.
126
127 =back
128
129 =head1 METHODS
130
131 =over 4 
132
133 =item new HASHREF
134
135 Creates a new package definition.  To add the package definition to
136 the database, see L<"insert">.
137
138 =cut
139
140 sub table { 'part_pkg'; }
141
142 =item clone
143
144 An alternate constructor.  Creates a new package definition by duplicating
145 an existing definition.  A new pkgpart is assigned and the custom flag is
146 set to Y.  To add the package definition to the database, see L<"insert">.
147
148 =cut
149
150 sub clone {
151   my $self = shift;
152   my $class = ref($self);
153   my %hash = $self->hash;
154   $hash{'pkgpart'} = '';
155   $hash{'custom'} = 'Y';
156   #new FS::part_pkg ( \%hash ); # ?
157   new $class ( \%hash ); # ?
158 }
159
160 =item insert [ , OPTION => VALUE ... ]
161
162 Adds this package definition to the database.  If there is an error,
163 returns the error, otherwise returns false.
164
165 Currently available options are: I<pkg_svc>, I<primary_svc>, I<cust_pkg>, 
166 I<custnum_ref> and I<options>.
167
168 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
169 values, appropriate FS::pkg_svc records will be inserted.  I<hidden_svc> can 
170 be set to a hashref of svcparts and flag values ('Y' or '') to set the 
171 'hidden' field in these records.
172
173 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
174 FS::pkg_svc record will be updated.
175
176 If I<cust_pkg> is set to a pkgnum of a FS::cust_pkg record (or the FS::cust_pkg
177 record itself), the object will be updated to point to this package definition.
178
179 In conjunction with I<cust_pkg>, if I<custnum_ref> is set to a scalar reference,
180 the scalar will be updated with the custnum value from the cust_pkg record.
181
182 If I<tax_overrides> is set to a hashref with usage classes as keys and comma
183 separated tax class numbers as values, appropriate FS::part_pkg_taxoverride
184 records will be inserted.
185
186 If I<options> is set to a hashref of options, appropriate FS::part_pkg_option
187 records will be inserted.
188
189 =cut
190
191 sub insert {
192   my $self = shift;
193   my %options = @_;
194   warn "FS::part_pkg::insert called on $self with options ".
195        join(', ', map "$_=>$options{$_}", keys %options)
196     if $DEBUG;
197
198   local $SIG{HUP} = 'IGNORE';
199   local $SIG{INT} = 'IGNORE';
200   local $SIG{QUIT} = 'IGNORE';
201   local $SIG{TERM} = 'IGNORE';
202   local $SIG{TSTP} = 'IGNORE';
203   local $SIG{PIPE} = 'IGNORE';
204
205   my $oldAutoCommit = $FS::UID::AutoCommit;
206   local $FS::UID::AutoCommit = 0;
207   my $dbh = dbh;
208
209   warn "  inserting part_pkg record" if $DEBUG;
210   my $error = $self->SUPER::insert( $options{options} );
211   if ( $error ) {
212     $dbh->rollback if $oldAutoCommit;
213     return $error;
214   }
215
216   # set family_pkgpart
217   if ( $self->get('family_pkgpart') eq '' ) {
218     $self->set('family_pkgpart' => $self->pkgpart);
219     $error = $self->SUPER::replace;
220     if ( $error ) {
221       $dbh->rollback if $oldAutoCommit;
222       return $error;
223     }
224   }
225
226   warn "  inserting part_pkg_taxoverride records" if $DEBUG;
227   my %overrides = %{ $options{'tax_overrides'} || {} };
228   foreach my $usage_class ( keys %overrides ) {
229     my $override =
230       ( exists($overrides{$usage_class}) && defined($overrides{$usage_class}) )
231         ? $overrides{$usage_class}
232         : '';
233     my @overrides = (grep "$_", split(',', $override) );
234     my $error = $self->process_m2m (
235                   'link_table'   => 'part_pkg_taxoverride',
236                   'target_table' => 'tax_class',
237                   'hashref'      => { 'usage_class' => $usage_class },
238                   'params'       => \@overrides,
239                 );
240     if ( $error ) {
241       $dbh->rollback if $oldAutoCommit;
242       return $error;
243     }
244   }
245
246   unless ( $skip_pkg_svc_hack ) {
247
248     warn "  inserting pkg_svc records" if $DEBUG;
249     my $pkg_svc = $options{'pkg_svc'} || {};
250     my $hidden_svc = $options{'hidden_svc'} || {};
251     foreach my $part_svc ( qsearch('part_svc', {} ) ) {
252       my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
253       my $primary_svc =
254         ( $options{'primary_svc'} && $options{'primary_svc'}==$part_svc->svcpart )
255           ? 'Y'
256           : '';
257
258       my $pkg_svc = new FS::pkg_svc( {
259         'pkgpart'     => $self->pkgpart,
260         'svcpart'     => $part_svc->svcpart,
261         'quantity'    => $quantity, 
262         'primary_svc' => $primary_svc,
263         'hidden'      => $hidden_svc->{$part_svc->svcpart},
264       } );
265       my $error = $pkg_svc->insert;
266       if ( $error ) {
267         $dbh->rollback if $oldAutoCommit;
268         return $error;
269       }
270     }
271
272   }
273
274   if ( $options{'cust_pkg'} ) {
275     warn "  updating cust_pkg record " if $DEBUG;
276     my $old_cust_pkg =
277       ref($options{'cust_pkg'})
278         ? $options{'cust_pkg'}
279         : qsearchs('cust_pkg', { pkgnum => $options{'cust_pkg'} } );
280     ${ $options{'custnum_ref'} } = $old_cust_pkg->custnum
281       if $options{'custnum_ref'};
282     my %hash = $old_cust_pkg->hash;
283     $hash{'pkgpart'} = $self->pkgpart,
284     my $new_cust_pkg = new FS::cust_pkg \%hash;
285     local($FS::cust_pkg::disable_agentcheck) = 1;
286     my $error = $new_cust_pkg->replace($old_cust_pkg);
287     if ( $error ) {
288       $dbh->rollback if $oldAutoCommit;
289       return "Error modifying cust_pkg record: $error";
290     }
291   }
292
293   if ( $options{'part_pkg_vendor'} ) {
294       while ( my ($exportnum, $vendor_pkg_id) =
295                 each %{ $options{part_pkg_vendor} }
296             )
297       {
298             my $ppv = new FS::part_pkg_vendor( {
299                     'pkgpart' => $self->pkgpart,
300                     'exportnum' => $exportnum,
301                     'vendor_pkg_id' => $vendor_pkg_id, 
302                 } );
303             my $error = $ppv->insert;
304             if ( $error ) {
305               $dbh->rollback if $oldAutoCommit;
306               return "Error inserting part_pkg_vendor record: $error";
307             }
308       }
309   }
310
311   if ( $options{fcc_options} ) {
312     warn "  updating fcc options " if $DEBUG;
313     $self->set_fcc_options( $options{fcc_options} );
314   }
315
316   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
317   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
318
319   '';
320 }
321
322 =item delete
323
324 Currently unimplemented.
325
326 =cut
327
328 sub delete {
329   return "Can't (yet?) delete package definitions.";
330 # check & make sure the pkgpart isn't in cust_pkg or type_pkgs?
331 }
332
333 =item replace OLD_RECORD [ , OPTION => VALUE ... ]
334
335 Replaces OLD_RECORD with this one in the database.  If there is an error,
336 returns the error, otherwise returns false.
337
338 Currently available options are: I<pkg_svc>, I<hidden_svc>, I<primary_svc> 
339 and I<options>
340
341 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
342 values, the appropriate FS::pkg_svc records will be replaced.  I<hidden_svc>
343 can be set to a hashref of svcparts and flag values ('Y' or '') to set the 
344 'hidden' field in these records.
345
346 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
347 FS::pkg_svc record will be updated.
348
349 If I<options> is set to a hashref, the appropriate FS::part_pkg_option records
350 will be replaced.
351
352 =cut
353
354 sub replace {
355   my $new = shift;
356
357   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
358               ? shift
359               : $new->replace_old;
360
361   my $options = 
362     ( ref($_[0]) eq 'HASH' )
363       ? shift
364       : { @_ };
365
366   $options->{options} = { $old->options } unless defined($options->{options});
367
368   warn "FS::part_pkg::replace called on $new to replace $old with options".
369        join(', ', map "$_ => ". $options->{$_}, keys %$options)
370     if $DEBUG;
371
372   local $SIG{HUP} = 'IGNORE';
373   local $SIG{INT} = 'IGNORE';
374   local $SIG{QUIT} = 'IGNORE';
375   local $SIG{TERM} = 'IGNORE';
376   local $SIG{TSTP} = 'IGNORE';
377   local $SIG{PIPE} = 'IGNORE';
378
379   my $oldAutoCommit = $FS::UID::AutoCommit;
380   local $FS::UID::AutoCommit = 0;
381   my $dbh = dbh;
382   
383   my $conf = new FS::Conf;
384   if ( $conf->exists('part_pkg-lineage') ) {
385     if ( grep { $options->{options}->{$_} ne $old->option($_, 1) }
386           qw(setup_fee recur_fee) #others? config?
387         ) { 
388     
389       warn "  superseding package" if $DEBUG;
390
391       my $error = $new->supersede($old, %$options);
392       if ( $error ) {
393         $dbh->rollback if $oldAutoCommit;
394         return $error;
395       }
396       else {
397         warn "  committing transaction" if $DEBUG and $oldAutoCommit;
398         $dbh->commit if $oldAutoCommit;
399         return $error;
400       }
401     }
402     #else nothing
403   }
404
405   #plandata shit stays in replace for upgrades until after 2.0 (or edit
406   #_upgrade_data)
407   warn "  saving legacy plandata" if $DEBUG;
408   my $plandata = $new->get('plandata');
409   $new->set('plandata', '');
410
411   warn "  deleting old part_pkg_option records" if $DEBUG;
412   foreach my $part_pkg_option ( $old->part_pkg_option ) {
413     my $error = $part_pkg_option->delete;
414     if ( $error ) {
415       $dbh->rollback if $oldAutoCommit;
416       return $error;
417     }
418   }
419
420   warn "  replacing part_pkg record" if $DEBUG;
421   my $error = $new->SUPER::replace($old, $options->{options} );
422   if ( $error ) {
423     $dbh->rollback if $oldAutoCommit;
424     return $error;
425   }
426
427   warn "  inserting part_pkg_option records for plandata: $plandata|" if $DEBUG;
428   foreach my $part_pkg_option ( 
429     map { /^(\w+)=(.*)$/ or do { $dbh->rollback if $oldAutoCommit;
430                                  return "illegal plandata: $plandata";
431                                };
432           new FS::part_pkg_option {
433             'pkgpart'     => $new->pkgpart,
434             'optionname'  => $1,
435             'optionvalue' => $2,
436           };
437         }
438     split("\n", $plandata)
439   ) {
440     my $error = $part_pkg_option->insert;
441     if ( $error ) {
442       $dbh->rollback if $oldAutoCommit;
443       return $error;
444     }
445   }
446
447   warn "  replacing pkg_svc records" if $DEBUG;
448   my $pkg_svc = $options->{'pkg_svc'};
449   my $hidden_svc = $options->{'hidden_svc'} || {};
450   if ( $pkg_svc ) { # if it wasn't passed, don't change existing pkg_svcs
451     foreach my $part_svc ( qsearch('part_svc', {} ) ) {
452       my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
453       my $hidden = $hidden_svc->{$part_svc->svcpart} || '';
454       my $primary_svc =
455         ( defined($options->{'primary_svc'}) && $options->{'primary_svc'}
456           && $options->{'primary_svc'} == $part_svc->svcpart
457         )
458           ? 'Y'
459           : '';
460
461       my $old_pkg_svc = qsearchs('pkg_svc', {
462           'pkgpart' => $old->pkgpart,
463           'svcpart' => $part_svc->svcpart,
464         }
465       );
466       my $old_quantity = 0;
467       my $old_primary_svc = '';
468       my $old_hidden = '';
469       if ( $old_pkg_svc ) {
470         $old_quantity = $old_pkg_svc->quantity;
471         $old_primary_svc = $old_pkg_svc->primary_svc 
472           if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed?
473         $old_hidden = $old_pkg_svc->hidden;
474       }
475    
476       next unless $old_quantity != $quantity || 
477                   $old_primary_svc ne $primary_svc ||
478                   $old_hidden ne $hidden;
479     
480       my $new_pkg_svc = new FS::pkg_svc( {
481         'pkgsvcnum'   => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
482         'pkgpart'     => $new->pkgpart,
483         'svcpart'     => $part_svc->svcpart,
484         'quantity'    => $quantity, 
485         'primary_svc' => $primary_svc,
486         'hidden'      => $hidden,
487       } );
488       my $error = $old_pkg_svc
489                     ? $new_pkg_svc->replace($old_pkg_svc)
490                     : $new_pkg_svc->insert;
491       if ( $error ) {
492         $dbh->rollback if $oldAutoCommit;
493         return $error;
494       }
495     } #foreach $part_svc
496   } #if $options->{pkg_svc}
497   
498   my @part_pkg_vendor = $old->part_pkg_vendor;
499   my @current_exportnum = ();
500   if ( $options->{'part_pkg_vendor'} ) {
501       my($exportnum,$vendor_pkg_id);
502       while ( ($exportnum,$vendor_pkg_id) 
503                                 = each %{$options->{'part_pkg_vendor'}} ) {
504           my $noinsert = 0;
505           foreach my $part_pkg_vendor ( @part_pkg_vendor ) {
506             if($exportnum == $part_pkg_vendor->exportnum
507                 && $vendor_pkg_id ne $part_pkg_vendor->vendor_pkg_id) {
508                 $part_pkg_vendor->vendor_pkg_id($vendor_pkg_id);
509                 my $error = $part_pkg_vendor->replace;
510                 if ( $error ) {
511                   $dbh->rollback if $oldAutoCommit;
512                   return "Error replacing part_pkg_vendor record: $error";
513                 }
514                 $noinsert = 1;
515                 last;
516             }
517             elsif($exportnum == $part_pkg_vendor->exportnum
518                 && $vendor_pkg_id eq $part_pkg_vendor->vendor_pkg_id) {
519                 $noinsert = 1;
520                 last;
521             }
522           }
523           unless ( $noinsert ) {
524             my $ppv = new FS::part_pkg_vendor( {
525                     'pkgpart' => $new->pkgpart,
526                     'exportnum' => $exportnum,
527                     'vendor_pkg_id' => $vendor_pkg_id, 
528                 } );
529             my $error = $ppv->insert;
530             if ( $error ) {
531               $dbh->rollback if $oldAutoCommit;
532               return "Error inserting part_pkg_vendor record: $error";
533             }
534           }
535           push @current_exportnum, $exportnum;
536       }
537   }
538   foreach my $part_pkg_vendor ( @part_pkg_vendor ) {
539       unless ( grep($_ eq $part_pkg_vendor->exportnum, @current_exportnum) ) {
540         my $error = $part_pkg_vendor->delete;
541         if ( $error ) {
542           $dbh->rollback if $oldAutoCommit;
543           return "Error deleting part_pkg_vendor record: $error";
544         }
545       }
546   }
547   
548   # propagate changes to certain core fields
549   if ( $conf->exists('part_pkg-lineage') ) {
550     warn "  propagating changes to family" if $DEBUG;
551     my $error = $new->propagate($old);
552     if ( $error ) {
553       $dbh->rollback if $oldAutoCommit;
554       return $error;
555     }
556   }
557
558   if ( $options->{fcc_options} ) {
559     warn "  updating fcc options " if $DEBUG;
560     $new->set_fcc_options( $options->{fcc_options} );
561   }
562
563   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
564   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
565   '';
566 }
567
568 =item check
569
570 Checks all fields to make sure this is a valid package definition.  If
571 there is an error, returns the error, otherwise returns false.  Called by the
572 insert and replace methods.
573
574 =cut
575
576 sub check {
577   my $self = shift;
578   warn "FS::part_pkg::check called on $self" if $DEBUG;
579
580   for (qw(setup recur plandata)) {
581     #$self->set($_=>0) if $self->get($_) =~ /^\s*$/; }
582     return "Use of $_ field is deprecated; set a plan and options: ".
583            $self->get($_)
584       if length($self->get($_));
585     $self->set($_, '');
586   }
587
588   if ( $self->dbdef_table->column('freq')->type =~ /(int)/i ) {
589     my $error = $self->ut_number('freq');
590     return $error if $error;
591   } else {
592     $self->freq =~ /^(\d+[hdw]?)$/
593       or return "Illegal or empty freq: ". $self->freq;
594     $self->freq($1);
595   }
596
597   my @null_agentnum_right = ( 'Edit global package definitions' );
598   push @null_agentnum_right, 'One-time charge'
599     if $self->freq =~ /^0/;
600   push @null_agentnum_right, 'Customize customer package'
601     if $self->disabled eq 'Y'; #good enough
602
603   my $error = $self->ut_numbern('pkgpart')
604     || $self->ut_text('pkg')
605     || $self->ut_textn('comment')
606     || $self->ut_textn('promo_code')
607     || $self->ut_alphan('plan')
608     || $self->ut_flag('setuptax')
609     || $self->ut_flag('recurtax')
610     || $self->ut_textn('taxclass')
611     || $self->ut_flag('disabled')
612     || $self->ut_flag('custom')
613     || $self->ut_flag('no_auto')
614     || $self->ut_flag('recur_show_zero')
615     || $self->ut_flag('setup_show_zero')
616     || $self->ut_flag('start_on_hold')
617     #|| $self->ut_moneyn('setup_cost')
618     #|| $self->ut_moneyn('recur_cost')
619     || $self->ut_floatn('setup_cost')
620     || $self->ut_floatn('recur_cost')
621     || $self->ut_floatn('pay_weight')
622     || $self->ut_floatn('credit_weight')
623     || $self->ut_numbern('taxproductnum')
624     || $self->ut_foreign_keyn('classnum',       'pkg_class', 'classnum')
625     || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum')
626     || $self->ut_foreign_keyn('taxproductnum',
627                               'part_pkg_taxproduct',
628                               'taxproductnum'
629                              )
630     || ( $setup_hack
631            ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' )
632            : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right)
633        )
634     || $self->ut_numbern('fcc_ds0s')
635     || $self->ut_numbern('fcc_voip_class')
636     || $self->ut_numbern('delay_start')
637     || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart')
638     || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart')
639     || $self->ut_alphan('agent_pkgpartid')
640     || $self->SUPER::check
641   ;
642   return $error if $error;
643
644   return 'Unknown plan '. $self->plan
645     unless exists($plans{$self->plan});
646
647   my $conf = new FS::Conf;
648   return 'Taxclass is required'
649     if ! $self->taxclass && $conf->exists('require_taxclasses');
650
651   '';
652 }
653
654 =item supersede OLD [, OPTION => VALUE ... ]
655
656 Inserts this package as a successor to the package OLD.  All options are as
657 for C<insert>.  After inserting, disables OLD and sets the new package as its
658 successor.
659
660 =cut
661
662 sub supersede {
663   my ($new, $old, %options) = @_;
664   my $error;
665
666   $new->set('pkgpart' => '');
667   $new->set('family_pkgpart' => $old->family_pkgpart);
668   warn "    inserting successor package\n" if $DEBUG;
669   $error = $new->insert(%options);
670   return $error if $error;
671  
672   warn "    disabling superseded package\n" if $DEBUG; 
673   $old->set('successor' => $new->pkgpart);
674   $old->set('disabled' => 'Y');
675   $error = $old->SUPER::replace; # don't change its options/pkg_svc records
676   return $error if $error;
677
678   warn "  propagating changes to family" if $DEBUG;
679   $new->propagate($old);
680 }
681
682 =item propagate OLD
683
684 If any of certain fields have changed from OLD to this package, then,
685 for all packages in the same lineage as this one, sets those fields 
686 to their values in this package.
687
688 =cut
689
690 my @propagate_fields = (
691   qw( pkg classnum setup_cost recur_cost taxclass
692   setuptax recurtax pay_weight credit_weight
693   )
694 );
695
696 sub propagate {
697   my $new = shift;
698   my $old = shift;
699   my %fields = (
700     map { $_ => $new->get($_) }
701     grep { $new->get($_) ne $old->get($_) }
702     @propagate_fields
703   );
704
705   my @part_pkg = qsearch('part_pkg', { 
706       'family_pkgpart' => $new->family_pkgpart 
707   });
708   my @error;
709   foreach my $part_pkg ( @part_pkg ) {
710     my $pkgpart = $part_pkg->pkgpart;
711     next if $pkgpart == $new->pkgpart; # don't modify $new
712     warn "    propagating to pkgpart $pkgpart\n" if $DEBUG;
713     foreach ( keys %fields ) {
714       $part_pkg->set($_, $fields{$_});
715     }
716     # SUPER::replace to avoid changing non-core fields
717     my $error = $part_pkg->SUPER::replace;
718     push @error, "pkgpart $pkgpart: $error"
719       if $error;
720   }
721   join("\n", @error);
722 }
723
724 =item set_fcc_options HASHREF
725
726 Sets the FCC options on this package definition to the values specified
727 in HASHREF.
728
729 =cut
730
731 sub set_fcc_options {
732   my $self = shift;
733   my $pkgpart = $self->pkgpart;
734   my $options;
735   if (ref $_[0]) {
736     $options = shift;
737   } else {
738     $options = { @_ };
739   }
740
741   my %existing_num = map { $_->fccoptionname => $_->num }
742                      qsearch('part_pkg_fcc_option', { pkgpart => $pkgpart });
743
744   local $FS::Record::nowarn_identical = 1;
745   # set up params for process_o2m
746   my $i = 0;
747   my $params = {};
748   foreach my $name (keys %$options ) {
749     $params->{ "num$i" } = $existing_num{$name} || '';
750     $params->{ "num$i".'_fccoptionname' } = $name;
751     $params->{ "num$i".'_optionvalue'   } = $options->{$name};
752     $i++;
753   }
754
755   $self->process_o2m(
756     table   => 'part_pkg_fcc_option',
757     fields  => [qw( fccoptionname optionvalue )],
758     params  => $params,
759   );
760 }
761
762 =item pkg_locale LOCALE
763
764 Returns a customer-viewable string representing this package for the given
765 locale, from the part_pkg_msgcat table.  If the given locale is empty or no
766 localized string is found, returns the base pkg field.
767
768 =cut
769
770 sub pkg_locale {
771   my( $self, $locale ) = @_;
772   return $self->pkg unless $locale;
773   my $part_pkg_msgcat = $self->part_pkg_msgcat($locale) or return $self->pkg;
774   $part_pkg_msgcat->pkg;
775 }
776
777 =item part_pkg_msgcat LOCALE
778
779 Like pkg_locale, but returns the FS::part_pkg_msgcat object itself.
780
781 =cut
782
783 sub part_pkg_msgcat {
784   my( $self, $locale ) = @_;
785   qsearchs( 'part_pkg_msgcat', {
786     pkgpart => $self->pkgpart,
787     locale  => $locale,
788   });
789 }
790
791 =item pkg_comment [ OPTION => VALUE... ]
792
793 Returns an (internal) string representing this package.  Currently,
794 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
795 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
796 custom is Y.
797
798 If the option nopkgpart is true then the "pkgpart: ' is omitted.
799
800 =cut
801
802 sub pkg_comment {
803   my $self = shift;
804   my %opt = @_;
805
806   #$self->pkg. ' - '. $self->comment;
807   #$self->pkg. ' ('. $self->comment. ')';
808   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
809   my $custom_comment = $self->custom_comment(%opt);
810   $pre. $self->pkg. ( $custom_comment ? " - $custom_comment" : '' );
811 }
812
813 #without price info (so without hitting the DB again)
814 sub pkg_comment_only {
815   my $self = shift;
816   my %opt = @_;
817
818   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
819   my $comment = $self->comment;
820   $pre. $self->pkg. ( $comment ? " - $comment" : '' );
821 }
822
823 sub price_info { # safety, in case a part_pkg hasn't defined price_info
824     '';
825 }
826
827 sub custom_comment {
828   my $self = shift;
829   my $price_info = $self->price_info(@_);
830   ( $self->custom ? '(CUSTOM) ' : '' ).
831     $self->comment.
832     ( ( ($self->custom || $self->comment) && $price_info ) ? ' - ' : '' ).
833     $price_info;
834 }
835
836 sub pkg_price_info {
837   my $self = shift;
838   $self->pkg. ' - '. ($self->price_info || 'No charge');
839 }
840
841 =item pkg_class
842
843 Returns the package class, as an FS::pkg_class object, or the empty string
844 if there is no package class.
845
846 =cut
847
848 sub pkg_class {
849   my $self = shift;
850   if ( $self->classnum ) {
851     qsearchs('pkg_class', { 'classnum' => $self->classnum } );
852   } else {
853     return '';
854   }
855 }
856
857 =item addon_pkg_class
858
859 Returns the add-on package class, as an FS::pkg_class object, or the empty
860 string if there is no add-on package class.
861
862 =cut
863
864 sub addon_pkg_class {
865   my $self = shift;
866   if ( $self->addon_classnum ) {
867     qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } );
868   } else {
869     return '';
870   }
871 }
872
873 =item categoryname 
874
875 Returns the package category name, or the empty string if there is no package
876 category.
877
878 =cut
879
880 sub categoryname {
881   my $self = shift;
882   my $pkg_class = $self->pkg_class;
883   $pkg_class
884     ? $pkg_class->categoryname
885     : '';
886 }
887
888 =item classname 
889
890 Returns the package class name, or the empty string if there is no package
891 class.
892
893 =cut
894
895 sub classname {
896   my $self = shift;
897   my $pkg_class = $self->pkg_class;
898   $pkg_class
899     ? $pkg_class->classname
900     : '';
901 }
902
903 =item addon_classname 
904
905 Returns the add-on package class name, or the empty string if there is no
906 add-on package class.
907
908 =cut
909
910 sub addon_classname {
911   my $self = shift;
912   my $pkg_class = $self->addon_pkg_class;
913   $pkg_class
914     ? $pkg_class->classname
915     : '';
916 }
917
918 =item agent 
919
920 Returns the associated agent for this event, if any, as an FS::agent object.
921
922 =cut
923
924 sub agent {
925   my $self = shift;
926   qsearchs('agent', { 'agentnum' => $self->agentnum } );
927 }
928
929 =item pkg_svc [ HASHREF | OPTION => VALUE ]
930
931 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
932 definition (with non-zero quantity).
933
934 One option is available, I<disable_linked>.  If set true it will return the
935 services for this package definition alone, omitting services from any add-on
936 packages.
937
938 =cut
939
940 =item type_pkgs
941
942 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
943 definition.
944
945 =cut
946
947 sub type_pkgs {
948   my $self = shift;
949   qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
950 }
951
952 sub pkg_svc {
953   my $self = shift;
954
955 #  #sort { $b->primary cmp $a->primary } 
956 #    grep { $_->quantity }
957 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
958
959   my $opt = ref($_[0]) ? $_[0] : { @_ };
960   my %pkg_svc = map  { $_->svcpart => $_ }
961                 grep { $_->quantity }
962                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
963
964   unless ( $opt->{disable_linked} ) {
965     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
966       my @pkg_svc = grep { $_->quantity }
967                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
968       foreach my $pkg_svc ( @pkg_svc ) {
969         if ( $pkg_svc{$pkg_svc->svcpart} ) {
970           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
971           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
972         } else {
973           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
974         }
975       }
976     }
977   }
978
979   values(%pkg_svc);
980
981 }
982
983 =item svcpart [ SVCDB ]
984
985 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
986 associated with this package definition (see L<FS::pkg_svc>).  Returns
987 false if there not a primary service definition or exactly one service
988 definition with quantity 1, or if SVCDB is specified and does not match the
989 svcdb of the service definition.  SVCDB can be specified as a scalar table
990 name, such as 'svc_acct', or as an arrayref of possible table names.
991
992 =cut
993
994 sub svcpart {
995   my $pkg_svc = shift->_primary_pkg_svc(@_);
996   $pkg_svc ? $pkg_svc->svcpart : '';
997 }
998
999 =item part_svc [ SVCDB ]
1000
1001 Like the B<svcpart> method, but returns the FS::part_svc object (see
1002 L<FS::part_svc>).
1003
1004 =cut
1005
1006 sub part_svc {
1007   my $pkg_svc = shift->_primary_pkg_svc(@_);
1008   $pkg_svc ? $pkg_svc->part_svc : '';
1009 }
1010
1011 sub _primary_pkg_svc {
1012   my $self = shift;
1013
1014   my $svcdb = scalar(@_) ? shift : [];
1015   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
1016   my %svcdb = map { $_=>1 } @$svcdb;
1017
1018   my @svcdb_pkg_svc =
1019     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
1020          $self->pkg_svc;
1021
1022   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
1023   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
1024     unless @pkg_svc;
1025   return '' if scalar(@pkg_svc) != 1;
1026   $pkg_svc[0];
1027 }
1028
1029 =item svcpart_unique_svcdb SVCDB
1030
1031 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
1032 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
1033 false if there not a primary service definition for SVCDB or there are multiple
1034 service definitions for SVCDB.
1035
1036 =cut
1037
1038 sub svcpart_unique_svcdb {
1039   my( $self, $svcdb ) = @_;
1040   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
1041   return '' if scalar(@svcdb_pkg_svc) != 1;
1042   $svcdb_pkg_svc[0]->svcpart;
1043 }
1044
1045 =item payby
1046
1047 Returns a list of the acceptable payment types for this package.  Eventually
1048 this should come out of a database table and be editable, but currently has the
1049 following logic instead:
1050
1051 If the package is free, the single item B<BILL> is
1052 returned, otherwise, the single item B<CARD> is returned.
1053
1054 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
1055
1056 =cut
1057
1058 sub payby {
1059   my $self = shift;
1060   if ( $self->is_free ) {
1061     ( 'BILL' );
1062   } else {
1063     ( 'CARD' );
1064   }
1065 }
1066
1067 =item is_free
1068
1069 Returns true if this package is free.  
1070
1071 =cut
1072
1073 sub is_free {
1074   my $self = shift;
1075   if ( $self->can('is_free_options') ) {
1076     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
1077          map { $self->option($_) } 
1078              $self->is_free_options;
1079   } else {
1080     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
1081          "provides neither is_free_options nor is_free method; returning false";
1082     0;
1083   }
1084 }
1085
1086 # whether the plan allows discounts to be applied to this package
1087 sub can_discount { 0; }
1088
1089 # whether the plan allows changing the start date
1090 sub can_start_date {
1091   my $self = shift;
1092   $self->start_on_hold ? 0 : 1;
1093 }
1094
1095 # the delay start date if present
1096 sub delay_start_date {
1097   my $self = shift;
1098
1099   my $delay = $self->delay_start or return '';
1100
1101   # avoid timelocal silliness  
1102   my $dt = DateTime->today(time_zone => 'local');
1103   $dt->add(days => $delay);
1104   $dt->epoch;
1105 }
1106
1107 sub freqs_href {
1108   # moved to FS::Misc to make this accessible to other packages
1109   # at initialization
1110   FS::Misc::pkg_freqs();
1111 }
1112
1113 =item freq_pretty
1114
1115 Returns an english representation of the I<freq> field, such as "monthly",
1116 "weekly", "semi-annually", etc.
1117
1118 =cut
1119
1120 sub freq_pretty {
1121   my $self = shift;
1122   my $freq = $self->freq;
1123
1124   #my $freqs_href = $self->freqs_href;
1125   my $freqs_href = freqs_href();
1126
1127   if ( exists($freqs_href->{$freq}) ) {
1128     $freqs_href->{$freq};
1129   } else {
1130     my $interval = 'month';
1131     if ( $freq =~ /^(\d+)([hdw])$/ ) {
1132       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
1133       $interval = $interval{$2};
1134     }
1135     if ( $1 == 1 ) {
1136       "every $interval";
1137     } else {
1138       "every $freq ${interval}s";
1139     }
1140   }
1141 }
1142
1143 =item add_freq TIMESTAMP [ FREQ ]
1144
1145 Adds a billing period of some frequency to the provided timestamp and 
1146 returns the resulting timestamp, or -1 if the frequency could not be 
1147 parsed (shouldn't happen).  By default, the frequency of this package 
1148 will be used; to override this, pass a different frequency as a second 
1149 argument.
1150
1151 =cut
1152
1153 sub add_freq {
1154   my( $self, $date, $freq ) = @_;
1155   $freq = $self->freq unless $freq;
1156
1157   #change this bit to use Date::Manip? CAREFUL with timezones (see
1158   # mailing list archive)
1159   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
1160
1161   if ( $freq =~ /^\d+$/ ) {
1162     $mon += $freq;
1163     until ( $mon < 12 ) { $mon -= 12; $year++; }
1164
1165     $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback');
1166
1167   } elsif ( $freq =~ /^(\d+)w$/ ) {
1168     my $weeks = $1;
1169     $mday += $weeks * 7;
1170   } elsif ( $freq =~ /^(\d+)d$/ ) {
1171     my $days = $1;
1172     $mday += $days;
1173   } elsif ( $freq =~ /^(\d+)h$/ ) {
1174     my $hours = $1;
1175     $hour += $hours;
1176   } else {
1177     return -1;
1178   }
1179
1180   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
1181 }
1182
1183 =item plandata
1184
1185 For backwards compatibility, returns the plandata field as well as all options
1186 from FS::part_pkg_option.
1187
1188 =cut
1189
1190 sub plandata {
1191   my $self = shift;
1192   carp "plandata is deprecated";
1193   if ( @_ ) {
1194     $self->SUPER::plandata(@_);
1195   } else {
1196     my $plandata = $self->get('plandata');
1197     my %options = $self->options;
1198     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
1199     $plandata;
1200   }
1201 }
1202
1203 =item part_pkg_vendor
1204
1205 Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
1206 L<FS::part_pkg_vendor>).
1207
1208 =cut
1209
1210 sub part_pkg_vendor {
1211   my $self = shift;
1212   qsearch('part_pkg_vendor', { 'pkgpart' => $self->pkgpart } );
1213 }
1214
1215 =item vendor_pkg_ids
1216
1217 Returns a list of vendor/external package ids by exportnum
1218
1219 =cut
1220
1221 sub vendor_pkg_ids {
1222   my $self = shift;
1223   map { $_->exportnum => $_->vendor_pkg_id } $self->part_pkg_vendor;
1224 }
1225
1226 =item part_pkg_option
1227
1228 Returns all options as FS::part_pkg_option objects (see
1229 L<FS::part_pkg_option>).
1230
1231 =cut
1232
1233 sub part_pkg_option {
1234   my $self = shift;
1235   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
1236 }
1237
1238 =item options 
1239
1240 Returns a list of option names and values suitable for assigning to a hash.
1241
1242 =cut
1243
1244 sub options {
1245   my $self = shift;
1246   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
1247 }
1248
1249 =item option OPTIONNAME [ QUIET ]
1250
1251 Returns the option value for the given name, or the empty string.  If a true
1252 value is passed as the second argument, warnings about missing the option
1253 will be suppressed.
1254
1255 =cut
1256
1257 sub option {
1258   my( $self, $opt, $ornull ) = @_;
1259
1260   #cache: was pulled up in the original part_pkg query
1261   if ( $opt =~ /^(setup|recur)_fee$/ && defined($self->hashref->{"_$opt"}) ) {
1262     return $self->hashref->{"_$opt"};
1263   }
1264
1265   cluck "$self -> option: searching for $opt"
1266     if $DEBUG;
1267   my $part_pkg_option =
1268     qsearchs('part_pkg_option', {
1269       pkgpart    => $self->pkgpart,
1270       optionname => $opt,
1271   } );
1272   return $part_pkg_option->optionvalue if $part_pkg_option;
1273
1274   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
1275                      split("\n", $self->get('plandata') );
1276   return $plandata{$opt} if exists $plandata{$opt};
1277   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
1278         "not found in options or plandata!\n"
1279     unless $ornull;
1280
1281   '';
1282 }
1283
1284 =item fcc_option OPTIONNAME
1285
1286 Returns the FCC 477 report option value for the given name, or the empty 
1287 string.
1288
1289 =cut
1290
1291 sub fcc_option {
1292   my ($self, $name) = @_;
1293   my $part_pkg_fcc_option =
1294     qsearchs('part_pkg_fcc_option', {
1295         pkgpart => $self->pkgpart,
1296         fccoptionname => $name,
1297     });
1298   $part_pkg_fcc_option ? $part_pkg_fcc_option->optionvalue : '';
1299 }
1300
1301 =item fcc_options
1302
1303 Returns all FCC 477 report options for this package, as a hash-like list.
1304
1305 =cut
1306
1307 sub fcc_options {
1308   my $self = shift;
1309   map { $_->fccoptionname => $_->optionvalue }
1310     qsearch('part_pkg_fcc_option', { pkgpart => $self->pkgpart });
1311 }
1312
1313 =item bill_part_pkg_link
1314
1315 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1316
1317 =cut
1318
1319 sub bill_part_pkg_link {
1320   shift->_part_pkg_link('bill', @_);
1321 }
1322
1323 =item svc_part_pkg_link
1324
1325 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1326
1327 =cut
1328
1329 sub svc_part_pkg_link {
1330   shift->_part_pkg_link('svc', @_);
1331 }
1332
1333 =item supp_part_pkg_link
1334
1335 Returns the associated part_pkg_link records of type 'supp' (supplemental
1336 packages).
1337
1338 =cut
1339
1340 sub supp_part_pkg_link {
1341   shift->_part_pkg_link('supp', @_);
1342 }
1343
1344 sub _part_pkg_link {
1345   my( $self, $type ) = @_;
1346   qsearch({ table    => 'part_pkg_link',
1347             hashref  => { 'src_pkgpart' => $self->pkgpart,
1348                           'link_type'   => $type,
1349                           #protection against infinite recursive links
1350                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
1351                         },
1352             order_by => "ORDER BY hidden",
1353          });
1354 }
1355
1356 sub self_and_bill_linked {
1357   shift->_self_and_linked('bill', @_);
1358 }
1359
1360 sub self_and_svc_linked {
1361   shift->_self_and_linked('svc', @_);
1362 }
1363
1364 sub _self_and_linked {
1365   my( $self, $type, $hidden ) = @_;
1366   $hidden ||= '';
1367
1368   my @result = ();
1369   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
1370                      $self->_part_pkg_link($type) ) )
1371   {
1372     $_->hidden($hidden) if $hidden;
1373     push @result, $_;
1374   }
1375
1376   (@result);
1377 }
1378
1379 =item part_pkg_taxoverride [ CLASS ]
1380
1381 Returns all associated FS::part_pkg_taxoverride objects (see
1382 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
1383 of class CLASS if defined.  Class may be one of 'setup', 'recur',
1384 the empty string (default), or a usage class number (see L<FS::usage_class>).
1385 When a class is specified, the empty string class (default) is returned
1386 if no more specific values exist.
1387
1388 =cut
1389
1390 sub part_pkg_taxoverride {
1391   my $self = shift;
1392   my $class = shift;
1393
1394   my $hashref = { 'pkgpart' => $self->pkgpart };
1395   $hashref->{'usage_class'} = $class if defined($class);
1396   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1397
1398   unless ( scalar(@overrides) || !defined($class) || !$class ){
1399     $hashref->{'usage_class'} = '';
1400     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1401   }
1402
1403   @overrides;
1404 }
1405
1406 =item has_taxproduct
1407
1408 Returns true if this package has any taxproduct associated with it.  
1409
1410 =cut
1411
1412 sub has_taxproduct {
1413   my $self = shift;
1414
1415   $self->taxproductnum ||
1416   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1417           keys %{ {$self->options} }
1418   )
1419
1420 }
1421
1422
1423 =item taxproduct [ CLASS ]
1424
1425 Returns the associated tax product for this package definition (see
1426 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1427 the usage classnum (see L<FS::usage_class>).  Returns the default
1428 tax product for this record if the more specific CLASS value does
1429 not exist.
1430
1431 =cut
1432
1433 sub taxproduct {
1434   my $self = shift;
1435   my $class = shift;
1436
1437   my $part_pkg_taxproduct;
1438
1439   my $taxproductnum = $self->taxproductnum;
1440   if ($class) { 
1441     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1442     $taxproductnum = $class_taxproductnum
1443       if $class_taxproductnum
1444   }
1445   
1446   $part_pkg_taxproduct =
1447     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1448
1449   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1450     $taxproductnum = $self->taxproductnum;
1451     $part_pkg_taxproduct =
1452       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1453   }
1454
1455   $part_pkg_taxproduct;
1456 }
1457
1458 =item taxproduct_description [ CLASS ]
1459
1460 Returns the description of the associated tax product for this package
1461 definition (see L<FS::part_pkg_taxproduct>).
1462
1463 =cut
1464
1465 sub taxproduct_description {
1466   my $self = shift;
1467   my $part_pkg_taxproduct = $self->taxproduct(@_);
1468   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1469 }
1470
1471
1472 =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
1473
1474 Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
1475 package in the location specified by GEOCODE, for usage class CLASS (one of
1476 'setup', 'recur', null, or a C<usage_class> number).
1477
1478 =cut
1479
1480 sub tax_rates {
1481   my $self = shift;
1482   my ($vendor, $geocode, $class) = @_;
1483   # if this part_pkg is overridden into a specific taxclass, get that class
1484   my @taxclassnums = map { $_->taxclassnum } 
1485                      $self->part_pkg_taxoverride($class);
1486   # otherwise, get its tax product category
1487   if (!@taxclassnums) {
1488     my $part_pkg_taxproduct = $self->taxproduct($class);
1489     # If this isn't defined, then the class has no taxproduct designation,
1490     # so return no tax rates.
1491     return () if !$part_pkg_taxproduct;
1492
1493     # convert the taxproduct to the tax classes that might apply to it in 
1494     # $geocode
1495     @taxclassnums = map { $_->taxclassnum }
1496                     grep { $_->taxable eq 'Y' } # why do we need this?
1497                     $part_pkg_taxproduct->part_pkg_taxrate($geocode);
1498   }
1499   return unless @taxclassnums;
1500
1501   # then look up the actual tax_rate entries
1502   warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
1503       if $DEBUG;
1504   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
1505   my @taxes = qsearch({ 'table'     => 'tax_rate',
1506                         'hashref'   => { 'geocode'     => $geocode,
1507                                          'data_vendor' => $vendor,
1508                                          'disabled'    => '' },
1509                         'extra_sql' => $extra_sql,
1510                       });
1511   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
1512       if $DEBUG;
1513
1514   return @taxes;
1515 }
1516
1517 =item part_pkg_discount
1518
1519 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1520 for this package.
1521
1522 =cut
1523
1524 sub part_pkg_discount {
1525   my $self = shift;
1526   qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
1527 }
1528
1529 =item part_pkg_usage
1530
1531 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
1532 this package.
1533
1534 =cut
1535
1536 sub part_pkg_usage {
1537   my $self = shift;
1538   qsearch('part_pkg_usage', { 'pkgpart' => $self->pkgpart });
1539 }
1540
1541 =item _rebless
1542
1543 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1544 PLAN is the object's I<plan> field.  There should be better docs
1545 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1546
1547 =cut
1548
1549 sub _rebless {
1550   my $self = shift;
1551   my $plan = $self->plan;
1552   unless ( $plan ) {
1553     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1554       if $DEBUG;
1555     return $self;
1556   }
1557   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1558   my $class = ref($self). "::$plan";
1559   warn "reblessing $self into $class" if $DEBUG > 1;
1560   eval "use $class;";
1561   die $@ if $@;
1562   bless($self, $class) unless $@;
1563   $self;
1564 }
1565
1566 #fatal fallbacks
1567 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
1568 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
1569
1570 #fallback that return 0 for old legacy packages with no plan
1571 sub calc_remain { 0; }
1572 sub calc_units  { 0; }
1573
1574 #fallback for everything not based on flat.pm
1575 sub recur_temporality { 'upcoming'; }
1576 sub calc_cancel { 0; }
1577
1578 #fallback for everything except bulk.pm
1579 sub hide_svc_detail { 0; }
1580
1581 #fallback for packages that can't/won't summarize usage
1582 sub sum_usage { 0; }
1583
1584 =item recur_cost_permonth CUST_PKG
1585
1586 recur_cost divided by freq (only supported for monthly and longer frequencies)
1587
1588 =cut
1589
1590 sub recur_cost_permonth {
1591   my($self, $cust_pkg) = @_;
1592   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1593   sprintf('%.2f', ($self->recur_cost || 0) / $self->freq );
1594 }
1595
1596 =item cust_bill_pkg_recur CUST_PKG
1597
1598 Actual recurring charge for the specified customer package from customer's most
1599 recent invoice
1600
1601 =cut
1602
1603 sub cust_bill_pkg_recur {
1604   my($self, $cust_pkg) = @_;
1605   my $cust_bill_pkg = qsearchs({
1606     'table'     => 'cust_bill_pkg',
1607     'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )',
1608     'hashref'   => { 'pkgnum' => $cust_pkg->pkgnum,
1609                      'recur'  => { op=>'>', value=>'0' },
1610                    },
1611     'order_by'  => 'ORDER BY cust_bill._date     DESC,
1612                              cust_bill_pkg.sdate DESC
1613                      LIMIT 1
1614                    ',
1615   }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition";
1616   $cust_bill_pkg->recur;
1617 }
1618
1619 =item unit_setup CUST_PKG
1620
1621 Returns the setup fee for one unit of the package.
1622
1623 =cut
1624
1625 sub unit_setup {
1626   my ($self, $cust_pkg) = @_;
1627   $self->option('setup_fee') || 0;
1628 }
1629
1630 =item setup_margin
1631
1632 unit_setup minus setup_cost
1633
1634 =cut
1635
1636 sub setup_margin {
1637   my $self = shift;
1638   $self->unit_setup(@_) - ($self->setup_cost || 0);
1639 }
1640
1641 =item recur_margin_permonth
1642
1643 base_recur_permonth minus recur_cost_permonth
1644
1645 =cut
1646
1647 sub recur_margin_permonth {
1648   my $self = shift;
1649   $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
1650 }
1651
1652 =item format OPTION DATA
1653
1654 Returns data formatted according to the function 'format' described
1655 in the plan info.  Returns DATA if no such function exists.
1656
1657 =cut
1658
1659 sub format {
1660   my ($self, $option, $data) = (shift, shift, shift);
1661   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1662     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1663   }else{
1664     $data;
1665   }
1666 }
1667
1668 =item parse OPTION DATA
1669
1670 Returns data parsed according to the function 'parse' described
1671 in the plan info.  Returns DATA if no such function exists.
1672
1673 =cut
1674
1675 sub parse {
1676   my ($self, $option, $data) = (shift, shift, shift);
1677   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1678     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1679   }else{
1680     $data;
1681   }
1682 }
1683
1684 =back
1685
1686 =cut
1687
1688 =head1 CLASS METHODS
1689
1690 =over 4
1691
1692 =cut
1693
1694 # _upgrade_data
1695 #
1696 # Used by FS::Upgrade to migrate to a new database.
1697
1698 sub _upgrade_data { # class method
1699   my($class, %opts) = @_;
1700
1701   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1702
1703   my @part_pkg = qsearch({
1704     'table'     => 'part_pkg',
1705     'extra_sql' => "WHERE ". join(' OR ',
1706                      'plan IS NULL', "plan = '' ",
1707                    ),
1708   });
1709
1710   foreach my $part_pkg (@part_pkg) {
1711
1712     unless ( $part_pkg->plan ) {
1713       $part_pkg->plan('flat');
1714     }
1715
1716     $part_pkg->replace;
1717
1718   }
1719   # the rest can be done asynchronously
1720 }
1721
1722 sub queueable_upgrade {
1723   # now upgrade to the explicit custom flag
1724
1725   my $search = FS::Cursor->new({
1726     'table'     => 'part_pkg',
1727     'hashref'   => { disabled => 'Y', custom => '' },
1728     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1729   });
1730   my $dbh = dbh;
1731
1732   while (my $part_pkg = $search->fetch) {
1733     my $new = new FS::part_pkg { $part_pkg->hash };
1734     $new->custom('Y');
1735     my $comment = $part_pkg->comment;
1736     $comment =~ s/^\(CUSTOM\) //;
1737     $comment = '(none)' unless $comment =~ /\S/;
1738     $new->comment($comment);
1739
1740     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1741     my $primary = $part_pkg->svcpart;
1742     my $options = { $part_pkg->options };
1743
1744     my $error = $new->replace( $part_pkg,
1745                                'pkg_svc'     => $pkg_svc,
1746                                'primary_svc' => $primary,
1747                                'options'     => $options,
1748                              );
1749     if ($error) {
1750       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1751       $dbh->rollback;
1752     } else {
1753       $dbh->commit;
1754     }
1755   }
1756
1757   # set family_pkgpart on any packages that don't have it
1758   $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
1759   while (my $part_pkg = $search->fetch) {
1760     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
1761     my $error = $part_pkg->SUPER::replace;
1762     if ($error) {
1763       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1764       $dbh->rollback;
1765     } else {
1766       $dbh->commit;
1767     }
1768   }
1769
1770   my @part_pkg_option = qsearch('part_pkg_option',
1771     { 'optionname'  => 'unused_credit',
1772       'optionvalue' => 1,
1773     });
1774   foreach my $old_opt (@part_pkg_option) {
1775     my $pkgpart = $old_opt->pkgpart;
1776     my $error = $old_opt->delete;
1777     die $error if $error;
1778
1779     foreach (qw(unused_credit_cancel unused_credit_change)) {
1780       my $new_opt = new FS::part_pkg_option {
1781         'pkgpart'     => $pkgpart,
1782         'optionname'  => $_,
1783         'optionvalue' => 1,
1784       };
1785       $error = $new_opt->insert;
1786       die $error if $error;
1787     }
1788   }
1789
1790   # migrate use_disposition_taqua and use_disposition to disposition_in
1791   @part_pkg_option = qsearch('part_pkg_option',
1792     { 'optionname'  => { op => 'LIKE',
1793                          value => 'use_disposition%',
1794                        },
1795       'optionvalue' => 1,
1796     });
1797   my %newopts = map { $_->pkgpart => $_ } 
1798     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1799   foreach my $old_opt (@part_pkg_option) {
1800         my $pkgpart = $old_opt->pkgpart;
1801         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1802                                                                   : 'ANSWERED';
1803         my $error = $old_opt->delete;
1804         die $error if $error;
1805
1806         if ( exists($newopts{$pkgpart}) ) {
1807             my $opt = $newopts{$pkgpart};
1808             $opt->optionvalue($opt->optionvalue.",$newval");
1809             $error = $opt->replace;
1810             die $error if $error;
1811         } else {
1812             my $new_opt = new FS::part_pkg_option {
1813                 'pkgpart'     => $pkgpart,
1814                 'optionname'  => 'disposition_in',
1815                 'optionvalue' => $newval,
1816               };
1817               $error = $new_opt->insert;
1818               die $error if $error;
1819               $newopts{$pkgpart} = $new_opt;
1820         }
1821   }
1822
1823   # set any package with FCC voice lines to the "VoIP with broadband" category
1824   # for backward compatibility
1825   #
1826   # recover from a bad upgrade bug
1827   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1828   if (!FS::upgrade_journal->is_done($upgrade)) {
1829     my $bad_upgrade = qsearchs('upgrade_journal', 
1830       { upgrade => 'part_pkg_fcc_voip_class' }
1831     );
1832     if ( $bad_upgrade ) {
1833       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1834                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1835       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1836         qsearch({
1837           'select'    => '*',
1838           'table'     => 'h_part_pkg_option',
1839           'hashref'   => {},
1840           'extra_sql' => "$where AND history_action = 'delete'",
1841           'order_by'  => 'ORDER BY history_date ASC',
1842         });
1843       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1844         qsearch({
1845           'select'    => '*',
1846           'table'     => 'h_pkg_svc',
1847           'hashref'   => {},
1848           'extra_sql' => "$where AND history_action = 'replace_old'",
1849           'order_by'  => 'ORDER BY history_date ASC',
1850         });
1851       my %opt;
1852       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1853         my $pkgpart ||= $deleted->pkgpart;
1854         $opt{$pkgpart} ||= {
1855           options => {},
1856           pkg_svc => {},
1857           primary_svc => '',
1858           hidden_svc => {},
1859         };
1860         if ( $deleted->isa('FS::part_pkg_option') ) {
1861           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1862         } else { # pkg_svc
1863           my $svcpart = $deleted->svcpart;
1864           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1865           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1866           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1867         }
1868       }
1869       foreach my $pkgpart (keys %opt) {
1870         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1871         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1872         if ( $error ) {
1873           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1874         }
1875       }
1876     } # $bad_upgrade exists
1877     else { # do the original upgrade, but correctly this time
1878       my @part_pkg = qsearch('part_pkg', {
1879           fcc_ds0s        => { op => '>', value => 0 },
1880           fcc_voip_class  => ''
1881       });
1882       foreach my $part_pkg (@part_pkg) {
1883         $part_pkg->set(fcc_voip_class => 2);
1884         my @pkg_svc = $part_pkg->pkg_svc;
1885         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1886         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1887         my $error = $part_pkg->replace(
1888           $part_pkg->replace_old,
1889           options     => { $part_pkg->options },
1890           pkg_svc     => \%quantity,
1891           hidden_svc  => \%hidden,
1892           primary_svc => ($part_pkg->svcpart || ''),
1893         );
1894         die $error if $error;
1895       }
1896     }
1897     FS::upgrade_journal->set_done($upgrade);
1898   }
1899
1900 }
1901
1902 =item curuser_pkgs_sql
1903
1904 Returns an SQL fragment for searching for packages the current user can
1905 use, either via part_pkg.agentnum directly, or via agent type (see
1906 L<FS::type_pkgs>).
1907
1908 =cut
1909
1910 sub curuser_pkgs_sql {
1911   my $class = shift;
1912
1913   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1914
1915 }
1916
1917 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1918
1919 Returns an SQL fragment for searching for packages the provided agent or agents
1920 can use, either via part_pkg.agentnum directly, or via agent type (see
1921 L<FS::type_pkgs>).
1922
1923 =cut
1924
1925 sub agent_pkgs_sql {
1926   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1927   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1928
1929   $class->_pkgs_sql(@agentnums); #is this why
1930
1931 }
1932
1933 sub _pkgs_sql {
1934   my( $class, @agentnums ) = @_;
1935   my $agentnums = join(',', @agentnums);
1936
1937   "
1938     (
1939       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1940       OR ( agentnum IS NULL
1941            AND EXISTS ( SELECT 1
1942                           FROM type_pkgs
1943                             LEFT JOIN agent_type USING ( typenum )
1944                             LEFT JOIN agent AS typeagent USING ( typenum )
1945                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1946                             AND typeagent.agentnum IN ($agentnums)
1947                       )
1948          )
1949     )
1950   ";
1951
1952 }
1953
1954 =back
1955
1956 =head1 SUBROUTINES
1957
1958 =over 4
1959
1960 =item plan_info
1961
1962 =cut
1963
1964 #false laziness w/part_export & cdr
1965 my %info;
1966 foreach my $INC ( @INC ) {
1967   warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
1968   foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
1969     warn "attempting to load plan info from $file\n" if $DEBUG;
1970     $file =~ /\/(\w+)\.pm$/ or do {
1971       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1972       next;
1973     };
1974     my $mod = $1;
1975     my $info = eval "use FS::part_pkg::$mod; ".
1976                     "\\%FS::part_pkg::$mod\::info;";
1977     if ( $@ ) {
1978       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1979       next;
1980     }
1981     unless ( keys %$info ) {
1982       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1983       next;
1984     }
1985     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1986     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1987     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1988     #  next;
1989     #}
1990     $info{$mod} = $info;
1991     $info->{'weight'} ||= 0; # quiet warnings
1992   }
1993 }
1994
1995 # copy one level deep to allow replacement of fields and fieldorder
1996 tie %plans, 'Tie::IxHash',
1997   map  { my %infohash = %{ $info{$_} }; 
1998           $_ => \%infohash }
1999   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
2000   keys %info;
2001
2002 # inheritance of plan options
2003 foreach my $name (keys(%info)) {
2004   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
2005     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
2006     delete $plans{$name};
2007     next;
2008   }
2009   my $parents = $info{$name}->{'inherit_fields'} || [];
2010   my (%fields, %field_exists, @fieldorder);
2011   foreach my $parent ($name, @$parents) {
2012     if ( !exists($info{$parent}) ) {
2013       warn "$name tried to inherit from nonexistent '$parent'\n";
2014       next;
2015     }
2016     %fields = ( # avoid replacing existing fields
2017       %{ $info{$parent}->{'fields'} || {} },
2018       %fields
2019     );
2020     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
2021       # avoid duplicates
2022       next if $field_exists{$_};
2023       $field_exists{$_} = 1;
2024       # allow inheritors to remove inherited fields from the fieldorder
2025       push @fieldorder, $_ if !exists($fields{$_}) or
2026                               !exists($fields{$_}->{'disabled'});
2027     }
2028   }
2029   $plans{$name}->{'fields'} = \%fields;
2030   $plans{$name}->{'fieldorder'} = \@fieldorder;
2031 }
2032
2033 sub plan_info {
2034   \%plans;
2035 }
2036
2037
2038 =back
2039
2040 =head1 NEW PLAN CLASSES
2041
2042 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
2043 found in eg/plan_template.pm.  Until then, it is suggested that you use the
2044 other modules in FS/FS/part_pkg/ as a guide.
2045
2046 =head1 BUGS
2047
2048 The delete method is unimplemented.
2049
2050 setup and recur semantics are not yet defined (and are implemented in
2051 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
2052
2053 plandata should go
2054
2055 part_pkg_taxrate is Pg specific
2056
2057 replace should be smarter about managing the related tables (options, pkg_svc)
2058
2059 =head1 SEE ALSO
2060
2061 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
2062 schema.html from the base documentation.
2063
2064 =cut
2065
2066 1;
2067