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