Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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->process_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->process_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 process_fcc_options HASHREF
791
792 Sets the FCC options on this package definition to the values specified
793 in HASHREF.  Names are as in L<FS::part_pkg_fcc_option/info>.
794
795 =cut
796
797 sub process_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   # set up params for process_o2m
811   my $i = 0;
812   my $params = {};
813   foreach my $name (keys %$options ) {
814     $params->{ "num$i" } = $existing_num{$name} || '';
815     $params->{ "num$i".'_fccoptionname' } = $name;
816     $params->{ "num$i".'_optionvalue'   } = $options->{$name};
817     $i++;
818   }
819
820   $self->process_o2m(
821     table   => 'part_pkg_fcc_option',
822     fields  => [qw( fccoptionname optionvalue )],
823     params  => $params,
824   );
825 }
826
827 =item pkg_locale LOCALE
828
829 Returns a customer-viewable string representing this package for the given
830 locale, from the part_pkg_msgcat table.  If the given locale is empty or no
831 localized string is found, returns the base pkg field.
832
833 =cut
834
835 sub pkg_locale {
836   my( $self, $locale ) = @_;
837   return $self->pkg unless $locale;
838   my $part_pkg_msgcat = $self->part_pkg_msgcat($locale) or return $self->pkg;
839   $part_pkg_msgcat->pkg;
840 }
841
842 =item part_pkg_msgcat LOCALE
843
844 Like pkg_locale, but returns the FS::part_pkg_msgcat object itself.
845
846 =cut
847
848 sub part_pkg_msgcat {
849   my( $self, $locale ) = @_;
850   qsearchs( 'part_pkg_msgcat', {
851     pkgpart => $self->pkgpart,
852     locale  => $locale,
853   });
854 }
855
856 =item pkg_comment [ OPTION => VALUE... ]
857
858 Returns an (internal) string representing this package.  Currently,
859 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
860 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
861 custom is Y.
862
863 If the option nopkgpart is true then the "pkgpart: ' is omitted.
864
865 =cut
866
867 sub pkg_comment {
868   my $self = shift;
869   my %opt = @_;
870
871   #$self->pkg. ' - '. $self->comment;
872   #$self->pkg. ' ('. $self->comment. ')';
873   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
874   my $custom_comment = $self->custom_comment(%opt);
875   $pre. $self->pkg. ( $custom_comment ? " - $custom_comment" : '' );
876 }
877
878 #without price info (so without hitting the DB again)
879 sub pkg_comment_only {
880   my $self = shift;
881   my %opt = @_;
882
883   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
884   my $comment = $self->comment;
885   $pre. $self->pkg. ( $comment ? " - $comment" : '' );
886 }
887
888 sub price_info { # safety, in case a part_pkg hasn't defined price_info
889     '';
890 }
891
892 sub custom_comment {
893   my $self = shift;
894   my $price_info = $self->price_info(@_);
895   ( $self->custom ? '(CUSTOM) ' : '' ).
896     $self->comment.
897     ( ($self->custom || $self->comment) ? ' - ' : '' ).
898     ($price_info || 'No charge');
899 }
900
901 sub pkg_price_info {
902   my $self = shift;
903   $self->pkg. ' - '. ($self->price_info || 'No charge');
904 }
905
906 =item pkg_class
907
908 Returns the package class, as an FS::pkg_class object, or the empty string
909 if there is no package class.
910
911 =item addon_pkg_class
912
913 Returns the add-on package class, as an FS::pkg_class object, or the empty
914 string if there is no add-on package class.
915
916 =cut
917
918 sub addon_pkg_class {
919   my $self = shift;
920   if ( $self->addon_classnum ) {
921     qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } );
922   } else {
923     return '';
924   }
925 }
926
927 =item categoryname 
928
929 Returns the package category name, or the empty string if there is no package
930 category.
931
932 =cut
933
934 sub categoryname {
935   my $self = shift;
936   my $pkg_class = $self->pkg_class;
937   $pkg_class
938     ? $pkg_class->categoryname
939     : '';
940 }
941
942 =item classname 
943
944 Returns the package class name, or the empty string if there is no package
945 class.
946
947 =cut
948
949 sub classname {
950   my $self = shift;
951   my $pkg_class = $self->pkg_class;
952   $pkg_class
953     ? $pkg_class->classname
954     : '';
955 }
956
957 =item addon_classname 
958
959 Returns the add-on package class name, or the empty string if there is no
960 add-on package class.
961
962 =cut
963
964 sub addon_classname {
965   my $self = shift;
966   my $pkg_class = $self->addon_pkg_class;
967   $pkg_class
968     ? $pkg_class->classname
969     : '';
970 }
971
972 =item agent 
973
974 Returns the associated agent for this event, if any, as an FS::agent object.
975
976 =item pkg_svc [ HASHREF | OPTION => VALUE ]
977
978 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
979 definition (with non-zero quantity).
980
981 One option is available, I<disable_linked>.  If set true it will return the
982 services for this package definition alone, omitting services from any add-on
983 packages.
984
985 =cut
986
987 =item type_pkgs
988
989 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
990 definition.
991
992 =cut
993
994 sub pkg_svc {
995   my $self = shift;
996
997 #  #sort { $b->primary cmp $a->primary } 
998 #    grep { $_->quantity }
999 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
1000
1001   my $opt = ref($_[0]) ? $_[0] : { @_ };
1002   my %pkg_svc = map  { $_->svcpart => $_ }
1003                 grep { $_->quantity }
1004                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
1005
1006   unless ( $opt->{disable_linked} ) {
1007     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
1008       my @pkg_svc = grep { $_->quantity }
1009                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
1010       foreach my $pkg_svc ( @pkg_svc ) {
1011         if ( $pkg_svc{$pkg_svc->svcpart} ) {
1012           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
1013           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
1014         } else {
1015           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
1016         }
1017       }
1018     }
1019   }
1020
1021   values(%pkg_svc);
1022
1023 }
1024
1025 =item svcpart [ SVCDB ]
1026
1027 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
1028 associated with this package definition (see L<FS::pkg_svc>).  Returns
1029 false if there not a primary service definition or exactly one service
1030 definition with quantity 1, or if SVCDB is specified and does not match the
1031 svcdb of the service definition.  SVCDB can be specified as a scalar table
1032 name, such as 'svc_acct', or as an arrayref of possible table names.
1033
1034 =cut
1035
1036 sub svcpart {
1037   my $pkg_svc = shift->_primary_pkg_svc(@_);
1038   $pkg_svc ? $pkg_svc->svcpart : '';
1039 }
1040
1041 =item part_svc [ SVCDB ]
1042
1043 Like the B<svcpart> method, but returns the FS::part_svc object (see
1044 L<FS::part_svc>).
1045
1046 =cut
1047
1048 sub part_svc {
1049   my $pkg_svc = shift->_primary_pkg_svc(@_);
1050   $pkg_svc ? $pkg_svc->part_svc : '';
1051 }
1052
1053 sub _primary_pkg_svc {
1054   my $self = shift;
1055
1056   my $svcdb = scalar(@_) ? shift : [];
1057   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
1058   my %svcdb = map { $_=>1 } @$svcdb;
1059
1060   my @svcdb_pkg_svc =
1061     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
1062          $self->pkg_svc;
1063
1064   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
1065   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
1066     unless @pkg_svc;
1067   return '' if scalar(@pkg_svc) != 1;
1068   $pkg_svc[0];
1069 }
1070
1071 =item svcpart_unique_svcdb SVCDB
1072
1073 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
1074 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
1075 false if there not a primary service definition for SVCDB or there are multiple
1076 service definitions for SVCDB.
1077
1078 =cut
1079
1080 sub svcpart_unique_svcdb {
1081   my( $self, $svcdb ) = @_;
1082   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
1083   return '' if scalar(@svcdb_pkg_svc) != 1;
1084   $svcdb_pkg_svc[0]->svcpart;
1085 }
1086
1087 =item payby
1088
1089 Returns a list of the acceptable payment types for this package.  Eventually
1090 this should come out of a database table and be editable, but currently has the
1091 following logic instead:
1092
1093 If the package is free, the single item B<BILL> is
1094 returned, otherwise, the single item B<CARD> is returned.
1095
1096 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
1097
1098 =cut
1099
1100 sub payby {
1101   my $self = shift;
1102   if ( $self->is_free ) {
1103     ( 'BILL' );
1104   } else {
1105     ( 'CARD' );
1106   }
1107 }
1108
1109 =item is_free
1110
1111 Returns true if this package is free.  
1112
1113 =cut
1114
1115 sub is_free {
1116   my $self = shift;
1117   if ( $self->can('is_free_options') ) {
1118     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
1119          map { $self->option($_) } 
1120              $self->is_free_options;
1121   } else {
1122     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
1123          "provides neither is_free_options nor is_free method; returning false";
1124     0;
1125   }
1126 }
1127
1128 # whether the plan allows discounts to be applied to this package
1129 sub can_discount { 0; }
1130  
1131 # whether the plan allows changing the start date
1132 sub can_start_date { 1; }
1133
1134 # whether the plan supports part_pkg_usageprice add-ons (a specific kind of
1135 #  pre-selectable usage pricing, there's others this doesn't refer to)
1136 sub can_usageprice { 0; }
1137   
1138 # the delay start date if present
1139 sub delay_start_date {
1140   my $self = shift;
1141
1142   my $delay = $self->delay_start or return '';
1143
1144   # avoid timelocal silliness  
1145   my $dt = DateTime->today(time_zone => 'local');
1146   $dt->add(days => $delay);
1147   $dt->epoch;
1148 }
1149
1150 sub can_currency_exchange { 0; }
1151
1152 sub freqs_href {
1153   # moved to FS::Misc to make this accessible to other packages
1154   # at initialization
1155   FS::Misc::pkg_freqs();
1156 }
1157
1158 =item freq_pretty
1159
1160 Returns an english representation of the I<freq> field, such as "monthly",
1161 "weekly", "semi-annually", etc.
1162
1163 =cut
1164
1165 sub freq_pretty {
1166   my $self = shift;
1167   my $freq = $self->freq;
1168
1169   #my $freqs_href = $self->freqs_href;
1170   my $freqs_href = freqs_href();
1171
1172   if ( exists($freqs_href->{$freq}) ) {
1173     $freqs_href->{$freq};
1174   } else {
1175     my $interval = 'month';
1176     if ( $freq =~ /^(\d+)([hdw])$/ ) {
1177       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
1178       $interval = $interval{$2};
1179     }
1180     if ( $1 == 1 ) {
1181       "every $interval";
1182     } else {
1183       "every $freq ${interval}s";
1184     }
1185   }
1186 }
1187
1188 =item add_freq TIMESTAMP [ FREQ ]
1189
1190 Adds a billing period of some frequency to the provided timestamp and 
1191 returns the resulting timestamp, or -1 if the frequency could not be 
1192 parsed (shouldn't happen).  By default, the frequency of this package 
1193 will be used; to override this, pass a different frequency as a second 
1194 argument.
1195
1196 =cut
1197
1198 sub add_freq {
1199   my( $self, $date, $freq ) = @_;
1200   $freq = $self->freq unless $freq;
1201
1202   #change this bit to use Date::Manip? CAREFUL with timezones (see
1203   # mailing list archive)
1204   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
1205
1206   if ( $freq =~ /^\d+$/ ) {
1207     $mon += $freq;
1208     until ( $mon < 12 ) { $mon -= 12; $year++; }
1209
1210     $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback');
1211
1212   } elsif ( $freq =~ /^(\d+)w$/ ) {
1213     my $weeks = $1;
1214     $mday += $weeks * 7;
1215   } elsif ( $freq =~ /^(\d+)d$/ ) {
1216     my $days = $1;
1217     $mday += $days;
1218   } elsif ( $freq =~ /^(\d+)h$/ ) {
1219     my $hours = $1;
1220     $hour += $hours;
1221   } else {
1222     return -1;
1223   }
1224
1225   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
1226 }
1227
1228 =item plandata
1229
1230 For backwards compatibility, returns the plandata field as well as all options
1231 from FS::part_pkg_option.
1232
1233 =cut
1234
1235 sub plandata {
1236   my $self = shift;
1237   carp "plandata is deprecated";
1238   if ( @_ ) {
1239     $self->SUPER::plandata(@_);
1240   } else {
1241     my $plandata = $self->get('plandata');
1242     my %options = $self->options;
1243     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
1244     $plandata;
1245   }
1246 }
1247
1248 =item part_pkg_vendor
1249
1250 Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
1251 L<FS::part_pkg_vendor>).
1252
1253 =item vendor_pkg_ids
1254
1255 Returns a list of vendor/external package ids by exportnum
1256
1257 =cut
1258
1259 sub vendor_pkg_ids {
1260   my $self = shift;
1261   map { $_->exportnum => $_->vendor_pkg_id } $self->part_pkg_vendor;
1262 }
1263
1264 =item part_pkg_option
1265
1266 Returns all options as FS::part_pkg_option objects (see
1267 L<FS::part_pkg_option>).
1268
1269 =item options 
1270
1271 Returns a list of option names and values suitable for assigning to a hash.
1272
1273 =cut
1274
1275 sub options {
1276   my $self = shift;
1277   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
1278 }
1279
1280 =item option OPTIONNAME [ QUIET ]
1281
1282 Returns the option value for the given name, or the empty string.  If a true
1283 value is passed as the second argument, warnings about missing the option
1284 will be suppressed.
1285
1286 =cut
1287
1288 sub option {
1289   my( $self, $opt, $ornull ) = @_;
1290   cluck "$self -> option: searching for $opt"
1291     if $DEBUG;
1292   my $part_pkg_option =
1293     qsearchs('part_pkg_option', {
1294       pkgpart    => $self->pkgpart,
1295       optionname => $opt,
1296   } );
1297   return $part_pkg_option->optionvalue if $part_pkg_option;
1298   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
1299                      split("\n", $self->get('plandata') );
1300   return $plandata{$opt} if exists $plandata{$opt};
1301   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
1302         "not found in options or plandata!\n"
1303     unless $ornull;
1304   '';
1305 }
1306
1307 =item part_pkg_currency [ CURRENCY ]
1308
1309 Returns all currency options as FS::part_pkg_currency objects (see
1310 L<FS::part_pkg_currency>), or, if a currency is specified, only return the
1311 objects for that currency.
1312
1313 =cut
1314
1315 sub part_pkg_currency {
1316   my $self = shift;
1317   my %hash = ( 'pkgpart' => $self->pkgpart );
1318   $hash{'currency'} = shift if @_;
1319   qsearch('part_pkg_currency', \%hash );
1320 }
1321
1322 =item part_pkg_currency_options CURRENCY
1323
1324 Returns a list of option names and values from FS::part_pkg_currency for the
1325 specified currency.
1326
1327 =cut
1328
1329 sub part_pkg_currency_options {
1330   my $self = shift;
1331   map { $_->optionname => $_->optionvalue } $self->part_pkg_currency(shift);
1332 }
1333
1334 =item part_pkg_currency_option CURRENCY OPTIONNAME
1335
1336 Returns the option value for the given name and currency.
1337
1338 =cut
1339
1340 sub part_pkg_currency_option {
1341   my( $self, $currency, $optionname ) = @_; 
1342   my $part_pkg_currency =
1343     qsearchs('part_pkg_currency', { 'pkgpart'    => $self->pkgpart,
1344                                     'currency'   => $currency,
1345                                     'optionname' => $optionname,
1346                                   }
1347             )#;
1348   #fatal if not found?  that works for our use cases from
1349   #part_pkg/currency_fixed, but isn't how we would typically/expect the method
1350   #to behave.  have to catch it there if we change it here...
1351     or die "Unknown price for ". $self->pkg_comment. " in $currency\n";
1352
1353   $part_pkg_currency->optionvalue;
1354 }
1355
1356 =item fcc_option OPTIONNAME
1357
1358 Returns the FCC 477 report option value for the given name, or the empty 
1359 string.
1360
1361 =cut
1362
1363 sub fcc_option {
1364   my ($self, $name) = @_;
1365   my $part_pkg_fcc_option =
1366     qsearchs('part_pkg_fcc_option', {
1367         pkgpart => $self->pkgpart,
1368         fccoptionname => $name,
1369     });
1370   $part_pkg_fcc_option ? $part_pkg_fcc_option->optionvalue : '';
1371 }
1372
1373 =item fcc_options
1374
1375 Returns all FCC 477 report options for this package, as a hash-like list.
1376
1377 =cut
1378
1379 sub fcc_options {
1380   my $self = shift;
1381   map { $_->fccoptionname => $_->optionvalue }
1382     qsearch('part_pkg_fcc_option', { pkgpart => $self->pkgpart });
1383 }
1384
1385 =item bill_part_pkg_link
1386
1387 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1388
1389 =cut
1390
1391 sub bill_part_pkg_link {
1392   shift->_part_pkg_link('bill', @_);
1393 }
1394
1395 =item svc_part_pkg_link
1396
1397 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1398
1399 =cut
1400
1401 sub svc_part_pkg_link {
1402   shift->_part_pkg_link('svc', @_);
1403 }
1404
1405 =item supp_part_pkg_link
1406
1407 Returns the associated part_pkg_link records of type 'supp' (supplemental
1408 packages).
1409
1410 =cut
1411
1412 sub supp_part_pkg_link {
1413   shift->_part_pkg_link('supp', @_);
1414 }
1415
1416 sub _part_pkg_link {
1417   my( $self, $type ) = @_;
1418   qsearch({ table    => 'part_pkg_link',
1419             hashref  => { 'src_pkgpart' => $self->pkgpart,
1420                           'link_type'   => $type,
1421                           #protection against infinite recursive links
1422                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
1423                         },
1424             order_by => "ORDER BY hidden",
1425          });
1426 }
1427
1428 sub self_and_bill_linked {
1429   shift->_self_and_linked('bill', @_);
1430 }
1431
1432 sub self_and_svc_linked {
1433   shift->_self_and_linked('svc', @_);
1434 }
1435
1436 sub _self_and_linked {
1437   my( $self, $type, $hidden ) = @_;
1438   $hidden ||= '';
1439
1440   my @result = ();
1441   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
1442                      $self->_part_pkg_link($type) ) )
1443   {
1444     $_->hidden($hidden) if $hidden;
1445     push @result, $_;
1446   }
1447
1448   (@result);
1449 }
1450
1451 =item part_pkg_taxoverride [ CLASS ]
1452
1453 Returns all associated FS::part_pkg_taxoverride objects (see
1454 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
1455 of class CLASS if defined.  Class may be one of 'setup', 'recur',
1456 the empty string (default), or a usage class number (see L<FS::usage_class>).
1457 When a class is specified, the empty string class (default) is returned
1458 if no more specific values exist.
1459
1460 =cut
1461
1462 sub part_pkg_taxoverride {
1463   my $self = shift;
1464   my $class = shift;
1465
1466   my $hashref = { 'pkgpart' => $self->pkgpart };
1467   $hashref->{'usage_class'} = $class if defined($class);
1468   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1469
1470   unless ( scalar(@overrides) || !defined($class) || !$class ){
1471     $hashref->{'usage_class'} = '';
1472     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1473   }
1474
1475   @overrides;
1476 }
1477
1478 =item has_taxproduct
1479
1480 Returns true if this package has any taxproduct associated with it.  
1481
1482 =cut
1483
1484 sub has_taxproduct {
1485   my $self = shift;
1486
1487   $self->taxproductnum ||
1488   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1489           keys %{ {$self->options} }
1490   )
1491
1492 }
1493
1494
1495 =item taxproduct [ CLASS ]
1496
1497 Returns the associated tax product for this package definition (see
1498 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1499 the usage classnum (see L<FS::usage_class>).  Returns the default
1500 tax product for this record if the more specific CLASS value does
1501 not exist.
1502
1503 =cut
1504
1505 sub taxproduct {
1506   my $self = shift;
1507   my $class = shift;
1508
1509   my $part_pkg_taxproduct;
1510
1511   my $taxproductnum = $self->taxproductnum;
1512   if ($class) { 
1513     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1514     $taxproductnum = $class_taxproductnum
1515       if $class_taxproductnum
1516   }
1517   
1518   $part_pkg_taxproduct =
1519     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1520
1521   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1522     $taxproductnum = $self->taxproductnum;
1523     $part_pkg_taxproduct =
1524       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1525   }
1526
1527   $part_pkg_taxproduct;
1528 }
1529
1530 =item taxproduct_description [ CLASS ]
1531
1532 Returns the description of the associated tax product for this package
1533 definition (see L<FS::part_pkg_taxproduct>).
1534
1535 =cut
1536
1537 sub taxproduct_description {
1538   my $self = shift;
1539   my $part_pkg_taxproduct = $self->taxproduct(@_);
1540   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1541 }
1542
1543
1544 =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
1545
1546 Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
1547 package in the location specified by GEOCODE, for usage class CLASS (one of
1548 'setup', 'recur', null, or a C<usage_class> number).
1549
1550 =cut
1551
1552 sub tax_rates {
1553   my $self = shift;
1554   my ($vendor, $geocode, $class) = @_;
1555   my @taxclassnums = map { $_->taxclassnum } 
1556                      $self->part_pkg_taxoverride($class);
1557   if (!@taxclassnums) {
1558     my $part_pkg_taxproduct = $self->taxproduct($class);
1559     # If this isn't defined, then the class has no taxproduct designation,
1560     # so return no tax rates.
1561     return () if !$part_pkg_taxproduct;
1562
1563     # convert the taxproduct to the tax classes that might apply to it in 
1564     # $geocode
1565     @taxclassnums = map { $_->taxclassnum }
1566                     grep { $_->taxable eq 'Y' } # why do we need this?
1567                     $part_pkg_taxproduct->part_pkg_taxrate($geocode);
1568   }
1569   return unless @taxclassnums;
1570
1571   # then look up the actual tax_rate entries
1572   warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
1573       if $DEBUG;
1574   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
1575   my @taxes = qsearch({ 'table'     => 'tax_rate',
1576                         'hashref'   => { 'geocode'     => $geocode,
1577                                          'data_vendor' => $vendor },
1578                         'extra_sql' => $extra_sql,
1579                       });
1580   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
1581       if $DEBUG;
1582
1583   return @taxes;
1584 }
1585
1586 =item part_pkg_discount
1587
1588 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1589 for this package.
1590
1591 =item part_pkg_usage
1592
1593 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
1594 this package.
1595
1596 =item _rebless
1597
1598 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1599 PLAN is the object's I<plan> field.  There should be better docs
1600 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1601
1602 =cut
1603
1604 sub _rebless {
1605   my $self = shift;
1606   my $plan = $self->plan;
1607   unless ( $plan ) {
1608     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1609       if $DEBUG;
1610     return $self;
1611   }
1612   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1613   my $class = ref($self). "::$plan";
1614   warn "reblessing $self into $class" if $DEBUG > 1;
1615   eval "use $class;";
1616   die $@ if $@;
1617   bless($self, $class) unless $@;
1618   $self;
1619 }
1620
1621 #fatal fallbacks
1622 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
1623 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
1624
1625 #fallback that return 0 for old legacy packages with no plan
1626 sub calc_remain { 0; }
1627 sub calc_units  { 0; }
1628
1629 #fallback for everything not based on flat.pm
1630 sub recur_temporality { 'upcoming'; }
1631 sub calc_cancel { 0; }
1632
1633 #fallback for everything except bulk.pm
1634 sub hide_svc_detail { 0; }
1635
1636 #fallback for packages that can't/won't summarize usage
1637 sub sum_usage { 0; }
1638
1639 =item recur_cost_permonth CUST_PKG
1640
1641 recur_cost divided by freq (only supported for monthly and longer frequencies)
1642
1643 =cut
1644
1645 sub recur_cost_permonth {
1646   my($self, $cust_pkg) = @_;
1647   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1648   sprintf('%.2f', $self->recur_cost / $self->freq );
1649 }
1650
1651 =item cust_bill_pkg_recur CUST_PKG
1652
1653 Actual recurring charge for the specified customer package from customer's most
1654 recent invoice
1655
1656 =cut
1657
1658 sub cust_bill_pkg_recur {
1659   my($self, $cust_pkg) = @_;
1660   my $cust_bill_pkg = qsearchs({
1661     'table'     => 'cust_bill_pkg',
1662     'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )',
1663     'hashref'   => { 'pkgnum' => $cust_pkg->pkgnum,
1664                      'recur'  => { op=>'>', value=>'0' },
1665                    },
1666     'order_by'  => 'ORDER BY cust_bill._date     DESC,
1667                              cust_bill_pkg.sdate DESC
1668                      LIMIT 1
1669                    ',
1670   }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition";
1671   $cust_bill_pkg->recur;
1672 }
1673
1674 =item unit_setup CUST_PKG
1675
1676 Returns the setup fee for one unit of the package.
1677
1678 =cut
1679
1680 sub unit_setup {
1681   my ($self, $cust_pkg) = @_;
1682   $self->option('setup_fee') || 0;
1683 }
1684
1685 =item setup_margin
1686
1687 unit_setup minus setup_cost
1688
1689 =cut
1690
1691 sub setup_margin {
1692   my $self = shift;
1693   $self->unit_setup(@_) - $self->setup_cost;
1694 }
1695
1696 =item recur_margin_permonth
1697
1698 base_recur_permonth minus recur_cost_permonth
1699
1700 =cut
1701
1702 sub recur_margin_permonth {
1703   my $self = shift;
1704   $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
1705 }
1706
1707 =item format OPTION DATA
1708
1709 Returns data formatted according to the function 'format' described
1710 in the plan info.  Returns DATA if no such function exists.
1711
1712 =cut
1713
1714 sub format {
1715   my ($self, $option, $data) = (shift, shift, shift);
1716   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1717     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1718   }else{
1719     $data;
1720   }
1721 }
1722
1723 =item parse OPTION DATA
1724
1725 Returns data parsed according to the function 'parse' described
1726 in the plan info.  Returns DATA if no such function exists.
1727
1728 =cut
1729
1730 sub parse {
1731   my ($self, $option, $data) = (shift, shift, shift);
1732   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1733     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1734   }else{
1735     $data;
1736   }
1737 }
1738
1739 =back
1740
1741 =cut
1742
1743 =head1 CLASS METHODS
1744
1745 =over 4
1746
1747 =cut
1748
1749 # _upgrade_data
1750 #
1751 # Used by FS::Upgrade to migrate to a new database.
1752
1753 sub _upgrade_data { # class method
1754   my($class, %opts) = @_;
1755
1756   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1757
1758   my @part_pkg = qsearch({
1759     'table'     => 'part_pkg',
1760     'extra_sql' => "WHERE ". join(' OR ',
1761                      'plan IS NULL', "plan = '' ",
1762                    ),
1763   });
1764
1765   foreach my $part_pkg (@part_pkg) {
1766
1767     unless ( $part_pkg->plan ) {
1768       $part_pkg->plan('flat');
1769     }
1770
1771     $part_pkg->replace;
1772
1773   }
1774   # the rest can be done asynchronously
1775 }
1776
1777 sub queueable_upgrade {
1778   # now upgrade to the explicit custom flag
1779
1780   my $search = FS::Cursor->new({
1781     'table'     => 'part_pkg',
1782     'hashref'   => { disabled => 'Y', custom => '' },
1783     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1784   });
1785   my $dbh = dbh;
1786
1787   while (my $part_pkg = $search->fetch) {
1788     my $new = new FS::part_pkg { $part_pkg->hash };
1789     $new->custom('Y');
1790     my $comment = $part_pkg->comment;
1791     $comment =~ s/^\(CUSTOM\) //;
1792     $comment = '(none)' unless $comment =~ /\S/;
1793     $new->comment($comment);
1794
1795     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1796     my $primary = $part_pkg->svcpart;
1797     my $options = { $part_pkg->options };
1798
1799     my $error = $new->replace( $part_pkg,
1800                                'pkg_svc'     => $pkg_svc,
1801                                'primary_svc' => $primary,
1802                                'options'     => $options,
1803                              );
1804     if ($error) {
1805       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1806       $dbh->rollback;
1807     } else {
1808       $dbh->commit;
1809     }
1810   }
1811
1812   # set family_pkgpart on any packages that don't have it
1813   $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
1814   while (my $part_pkg = $search->fetch) {
1815     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
1816     my $error = $part_pkg->SUPER::replace;
1817     if ($error) {
1818       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1819       $dbh->rollback;
1820     } else {
1821       $dbh->commit;
1822     }
1823   }
1824
1825   my @part_pkg_option = qsearch('part_pkg_option',
1826     { 'optionname'  => 'unused_credit',
1827       'optionvalue' => 1,
1828     });
1829   foreach my $old_opt (@part_pkg_option) {
1830     my $pkgpart = $old_opt->pkgpart;
1831     my $error = $old_opt->delete;
1832     die $error if $error;
1833
1834     foreach (qw(unused_credit_cancel unused_credit_change)) {
1835       my $new_opt = new FS::part_pkg_option {
1836         'pkgpart'     => $pkgpart,
1837         'optionname'  => $_,
1838         'optionvalue' => 1,
1839       };
1840       $error = $new_opt->insert;
1841       die $error if $error;
1842     }
1843   }
1844
1845   # migrate use_disposition_taqua and use_disposition to disposition_in
1846   @part_pkg_option = qsearch('part_pkg_option',
1847     { 'optionname'  => { op => 'LIKE',
1848                          value => 'use_disposition%',
1849                        },
1850       'optionvalue' => 1,
1851     });
1852   my %newopts = map { $_->pkgpart => $_ } 
1853     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1854   foreach my $old_opt (@part_pkg_option) {
1855         my $pkgpart = $old_opt->pkgpart;
1856         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1857                                                                   : 'ANSWERED';
1858         my $error = $old_opt->delete;
1859         die $error if $error;
1860
1861         if ( exists($newopts{$pkgpart}) ) {
1862             my $opt = $newopts{$pkgpart};
1863             $opt->optionvalue($opt->optionvalue.",$newval");
1864             $error = $opt->replace;
1865             die $error if $error;
1866         } else {
1867             my $new_opt = new FS::part_pkg_option {
1868                 'pkgpart'     => $pkgpart,
1869                 'optionname'  => 'disposition_in',
1870                 'optionvalue' => $newval,
1871               };
1872               $error = $new_opt->insert;
1873               die $error if $error;
1874               $newopts{$pkgpart} = $new_opt;
1875         }
1876   }
1877
1878   # set any package with FCC voice lines to the "VoIP with broadband" category
1879   # for backward compatibility
1880   #
1881   # recover from a bad upgrade bug
1882   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1883   if (!FS::upgrade_journal->is_done($upgrade)) {
1884     my $bad_upgrade = qsearchs('upgrade_journal', 
1885       { upgrade => 'part_pkg_fcc_voip_class' }
1886     );
1887     if ( $bad_upgrade ) {
1888       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1889                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1890       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1891         qsearch({
1892           'select'    => '*',
1893           'table'     => 'h_part_pkg_option',
1894           'hashref'   => {},
1895           'extra_sql' => "$where AND history_action = 'delete'",
1896           'order_by'  => 'ORDER BY history_date ASC',
1897         });
1898       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1899         qsearch({
1900           'select'    => '*',
1901           'table'     => 'h_pkg_svc',
1902           'hashref'   => {},
1903           'extra_sql' => "$where AND history_action = 'replace_old'",
1904           'order_by'  => 'ORDER BY history_date ASC',
1905         });
1906       my %opt;
1907       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1908         my $pkgpart ||= $deleted->pkgpart;
1909         $opt{$pkgpart} ||= {
1910           options => {},
1911           pkg_svc => {},
1912           primary_svc => '',
1913           hidden_svc => {},
1914         };
1915         if ( $deleted->isa('FS::part_pkg_option') ) {
1916           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1917         } else { # pkg_svc
1918           my $svcpart = $deleted->svcpart;
1919           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1920           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1921           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1922         }
1923       }
1924       foreach my $pkgpart (keys %opt) {
1925         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1926         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1927         if ( $error ) {
1928           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1929         }
1930       }
1931     } # $bad_upgrade exists
1932     else { # do the original upgrade, but correctly this time
1933       my @part_pkg = qsearch('part_pkg', {
1934           fcc_ds0s        => { op => '>', value => 0 },
1935           fcc_voip_class  => ''
1936       });
1937       foreach my $part_pkg (@part_pkg) {
1938         $part_pkg->set(fcc_voip_class => 2);
1939         my @pkg_svc = $part_pkg->pkg_svc;
1940         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1941         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1942         my $error = $part_pkg->replace(
1943           $part_pkg->replace_old,
1944           options     => { $part_pkg->options },
1945           pkg_svc     => \%quantity,
1946           hidden_svc  => \%hidden,
1947           primary_svc => ($part_pkg->svcpart || ''),
1948         );
1949         die $error if $error;
1950       }
1951     }
1952     FS::upgrade_journal->set_done($upgrade);
1953   }
1954
1955 }
1956
1957 =item curuser_pkgs_sql
1958
1959 Returns an SQL fragment for searching for packages the current user can
1960 use, either via part_pkg.agentnum directly, or via agent type (see
1961 L<FS::type_pkgs>).
1962
1963 =cut
1964
1965 sub curuser_pkgs_sql {
1966   my $class = shift;
1967
1968   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1969
1970 }
1971
1972 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1973
1974 Returns an SQL fragment for searching for packages the provided agent or agents
1975 can use, either via part_pkg.agentnum directly, or via agent type (see
1976 L<FS::type_pkgs>).
1977
1978 =cut
1979
1980 sub agent_pkgs_sql {
1981   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1982   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1983
1984   $class->_pkgs_sql(@agentnums); #is this why
1985
1986 }
1987
1988 sub _pkgs_sql {
1989   my( $class, @agentnums ) = @_;
1990   my $agentnums = join(',', @agentnums);
1991
1992   "
1993     (
1994       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1995       OR ( agentnum IS NULL
1996            AND EXISTS ( SELECT 1
1997                           FROM type_pkgs
1998                             LEFT JOIN agent_type USING ( typenum )
1999                             LEFT JOIN agent AS typeagent USING ( typenum )
2000                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
2001                             AND typeagent.agentnum IN ($agentnums)
2002                       )
2003          )
2004     )
2005   ";
2006
2007 }
2008
2009 =back
2010
2011 =head1 SUBROUTINES
2012
2013 =over 4
2014
2015 =item plan_info
2016
2017 =cut
2018
2019 #false laziness w/part_export & cdr
2020 my %info;
2021 foreach my $INC ( @INC ) {
2022   warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
2023   foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
2024     warn "attempting to load plan info from $file\n" if $DEBUG;
2025     $file =~ /\/(\w+)\.pm$/ or do {
2026       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
2027       next;
2028     };
2029     my $mod = $1;
2030     my $info = eval "use FS::part_pkg::$mod; ".
2031                     "\\%FS::part_pkg::$mod\::info;";
2032     if ( $@ ) {
2033       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
2034       next;
2035     }
2036     unless ( keys %$info ) {
2037       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
2038       next;
2039     }
2040     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
2041     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
2042     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
2043     #  next;
2044     #}
2045     $info{$mod} = $info;
2046     $info->{'weight'} ||= 0; # quiet warnings
2047   }
2048 }
2049
2050 # copy one level deep to allow replacement of fields and fieldorder
2051 tie %plans, 'Tie::IxHash',
2052   map  { my %infohash = %{ $info{$_} }; 
2053           $_ => \%infohash }
2054   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
2055   keys %info;
2056
2057 # inheritance of plan options
2058 foreach my $name (keys(%info)) {
2059   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
2060     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
2061     delete $plans{$name};
2062     next;
2063   }
2064   my $parents = $info{$name}->{'inherit_fields'} || [];
2065   my (%fields, %field_exists, @fieldorder);
2066   foreach my $parent ($name, @$parents) {
2067     if ( !exists($info{$parent}) ) {
2068       warn "$name tried to inherit from nonexistent '$parent'\n";
2069       next;
2070     }
2071     %fields = ( # avoid replacing existing fields
2072       %{ $info{$parent}->{'fields'} || {} },
2073       %fields
2074     );
2075     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
2076       # avoid duplicates
2077       next if $field_exists{$_};
2078       $field_exists{$_} = 1;
2079       # allow inheritors to remove inherited fields from the fieldorder
2080       push @fieldorder, $_ if !exists($fields{$_}) or
2081                               !exists($fields{$_}->{'disabled'});
2082     }
2083   }
2084   $plans{$name}->{'fields'} = \%fields;
2085   $plans{$name}->{'fieldorder'} = \@fieldorder;
2086 }
2087
2088 sub plan_info {
2089   \%plans;
2090 }
2091
2092
2093 =back
2094
2095 =head1 NEW PLAN CLASSES
2096
2097 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
2098 found in eg/plan_template.pm.  Until then, it is suggested that you use the
2099 other modules in FS/FS/part_pkg/ as a guide.
2100
2101 =head1 BUGS
2102
2103 The delete method is unimplemented.
2104
2105 setup and recur semantics are not yet defined (and are implemented in
2106 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
2107
2108 plandata should go
2109
2110 part_pkg_taxrate is Pg specific
2111
2112 replace should be smarter about managing the related tables (options, pkg_svc)
2113
2114 =head1 SEE ALSO
2115
2116 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
2117 schema.html from the base documentation.
2118
2119 =cut
2120
2121 1;
2122