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