477 report: improve browse-edit UI
[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   cluck "$self -> option: searching for $opt"
1292     if $DEBUG;
1293   my $part_pkg_option =
1294     qsearchs('part_pkg_option', {
1295       pkgpart    => $self->pkgpart,
1296       optionname => $opt,
1297   } );
1298   return $part_pkg_option->optionvalue if $part_pkg_option;
1299   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
1300                      split("\n", $self->get('plandata') );
1301   return $plandata{$opt} if exists $plandata{$opt};
1302   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
1303         "not found in options or plandata!\n"
1304     unless $ornull;
1305   '';
1306 }
1307
1308 =item part_pkg_currency [ CURRENCY ]
1309
1310 Returns all currency options as FS::part_pkg_currency objects (see
1311 L<FS::part_pkg_currency>), or, if a currency is specified, only return the
1312 objects for that currency.
1313
1314 =cut
1315
1316 sub part_pkg_currency {
1317   my $self = shift;
1318   my %hash = ( 'pkgpart' => $self->pkgpart );
1319   $hash{'currency'} = shift if @_;
1320   qsearch('part_pkg_currency', \%hash );
1321 }
1322
1323 =item part_pkg_currency_options CURRENCY
1324
1325 Returns a list of option names and values from FS::part_pkg_currency for the
1326 specified currency.
1327
1328 =cut
1329
1330 sub part_pkg_currency_options {
1331   my $self = shift;
1332   map { $_->optionname => $_->optionvalue } $self->part_pkg_currency(shift);
1333 }
1334
1335 =item part_pkg_currency_option CURRENCY OPTIONNAME
1336
1337 Returns the option value for the given name and currency.
1338
1339 =cut
1340
1341 sub part_pkg_currency_option {
1342   my( $self, $currency, $optionname ) = @_; 
1343   my $part_pkg_currency =
1344     qsearchs('part_pkg_currency', { 'pkgpart'    => $self->pkgpart,
1345                                     'currency'   => $currency,
1346                                     'optionname' => $optionname,
1347                                   }
1348             )#;
1349   #fatal if not found?  that works for our use cases from
1350   #part_pkg/currency_fixed, but isn't how we would typically/expect the method
1351   #to behave.  have to catch it there if we change it here...
1352     or die "Unknown price for ". $self->pkg_comment. " in $currency\n";
1353
1354   $part_pkg_currency->optionvalue;
1355 }
1356
1357 =item fcc_option OPTIONNAME
1358
1359 Returns the FCC 477 report option value for the given name, or the empty 
1360 string.
1361
1362 =cut
1363
1364 sub fcc_option {
1365   my ($self, $name) = @_;
1366   my $part_pkg_fcc_option =
1367     qsearchs('part_pkg_fcc_option', {
1368         pkgpart => $self->pkgpart,
1369         fccoptionname => $name,
1370     });
1371   $part_pkg_fcc_option ? $part_pkg_fcc_option->optionvalue : '';
1372 }
1373
1374 =item fcc_options
1375
1376 Returns all FCC 477 report options for this package, as a hash-like list.
1377
1378 =cut
1379
1380 sub fcc_options {
1381   my $self = shift;
1382   map { $_->fccoptionname => $_->optionvalue }
1383     qsearch('part_pkg_fcc_option', { pkgpart => $self->pkgpart });
1384 }
1385
1386 =item bill_part_pkg_link
1387
1388 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1389
1390 =cut
1391
1392 sub bill_part_pkg_link {
1393   shift->_part_pkg_link('bill', @_);
1394 }
1395
1396 =item svc_part_pkg_link
1397
1398 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1399
1400 =cut
1401
1402 sub svc_part_pkg_link {
1403   shift->_part_pkg_link('svc', @_);
1404 }
1405
1406 =item supp_part_pkg_link
1407
1408 Returns the associated part_pkg_link records of type 'supp' (supplemental
1409 packages).
1410
1411 =cut
1412
1413 sub supp_part_pkg_link {
1414   shift->_part_pkg_link('supp', @_);
1415 }
1416
1417 sub _part_pkg_link {
1418   my( $self, $type ) = @_;
1419   qsearch({ table    => 'part_pkg_link',
1420             hashref  => { 'src_pkgpart' => $self->pkgpart,
1421                           'link_type'   => $type,
1422                           #protection against infinite recursive links
1423                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
1424                         },
1425             order_by => "ORDER BY hidden",
1426          });
1427 }
1428
1429 sub self_and_bill_linked {
1430   shift->_self_and_linked('bill', @_);
1431 }
1432
1433 sub self_and_svc_linked {
1434   shift->_self_and_linked('svc', @_);
1435 }
1436
1437 sub _self_and_linked {
1438   my( $self, $type, $hidden ) = @_;
1439   $hidden ||= '';
1440
1441   my @result = ();
1442   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
1443                      $self->_part_pkg_link($type) ) )
1444   {
1445     $_->hidden($hidden) if $hidden;
1446     push @result, $_;
1447   }
1448
1449   (@result);
1450 }
1451
1452 =item part_pkg_taxoverride [ CLASS ]
1453
1454 Returns all associated FS::part_pkg_taxoverride objects (see
1455 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
1456 of class CLASS if defined.  Class may be one of 'setup', 'recur',
1457 the empty string (default), or a usage class number (see L<FS::usage_class>).
1458 When a class is specified, the empty string class (default) is returned
1459 if no more specific values exist.
1460
1461 =cut
1462
1463 sub part_pkg_taxoverride {
1464   my $self = shift;
1465   my $class = shift;
1466
1467   my $hashref = { 'pkgpart' => $self->pkgpart };
1468   $hashref->{'usage_class'} = $class if defined($class);
1469   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1470
1471   unless ( scalar(@overrides) || !defined($class) || !$class ){
1472     $hashref->{'usage_class'} = '';
1473     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1474   }
1475
1476   @overrides;
1477 }
1478
1479 =item has_taxproduct
1480
1481 Returns true if this package has any taxproduct associated with it.  
1482
1483 =cut
1484
1485 sub has_taxproduct {
1486   my $self = shift;
1487
1488   $self->taxproductnum ||
1489   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1490           keys %{ {$self->options} }
1491   )
1492
1493 }
1494
1495
1496 =item taxproduct [ CLASS ]
1497
1498 Returns the associated tax product for this package definition (see
1499 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1500 the usage classnum (see L<FS::usage_class>).  Returns the default
1501 tax product for this record if the more specific CLASS value does
1502 not exist.
1503
1504 =cut
1505
1506 sub taxproduct {
1507   my $self = shift;
1508   my $class = shift;
1509
1510   my $part_pkg_taxproduct;
1511
1512   my $taxproductnum = $self->taxproductnum;
1513   if ($class) { 
1514     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1515     $taxproductnum = $class_taxproductnum
1516       if $class_taxproductnum
1517   }
1518   
1519   $part_pkg_taxproduct =
1520     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1521
1522   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1523     $taxproductnum = $self->taxproductnum;
1524     $part_pkg_taxproduct =
1525       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1526   }
1527
1528   $part_pkg_taxproduct;
1529 }
1530
1531 =item taxproduct_description [ CLASS ]
1532
1533 Returns the description of the associated tax product for this package
1534 definition (see L<FS::part_pkg_taxproduct>).
1535
1536 =cut
1537
1538 sub taxproduct_description {
1539   my $self = shift;
1540   my $part_pkg_taxproduct = $self->taxproduct(@_);
1541   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1542 }
1543
1544
1545 =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
1546
1547 Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
1548 package in the location specified by GEOCODE, for usage class CLASS (one of
1549 'setup', 'recur', null, or a C<usage_class> number).
1550
1551 =cut
1552
1553 sub tax_rates {
1554   my $self = shift;
1555   my ($vendor, $geocode, $class) = @_;
1556   my @taxclassnums = map { $_->taxclassnum } 
1557                      $self->part_pkg_taxoverride($class);
1558   if (!@taxclassnums) {
1559     my $part_pkg_taxproduct = $self->taxproduct($class);
1560     # If this isn't defined, then the class has no taxproduct designation,
1561     # so return no tax rates.
1562     return () if !$part_pkg_taxproduct;
1563
1564     # convert the taxproduct to the tax classes that might apply to it in 
1565     # $geocode
1566     @taxclassnums = map { $_->taxclassnum }
1567                     grep { $_->taxable eq 'Y' } # why do we need this?
1568                     $part_pkg_taxproduct->part_pkg_taxrate($geocode);
1569   }
1570   return unless @taxclassnums;
1571
1572   # then look up the actual tax_rate entries
1573   warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
1574       if $DEBUG;
1575   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
1576   my @taxes = qsearch({ 'table'     => 'tax_rate',
1577                         'hashref'   => { 'geocode'     => $geocode,
1578                                          'data_vendor' => $vendor },
1579                         'extra_sql' => $extra_sql,
1580                       });
1581   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
1582       if $DEBUG;
1583
1584   return @taxes;
1585 }
1586
1587 =item part_pkg_discount
1588
1589 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1590 for this package.
1591
1592 =item part_pkg_usage
1593
1594 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
1595 this package.
1596
1597 =item _rebless
1598
1599 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1600 PLAN is the object's I<plan> field.  There should be better docs
1601 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1602
1603 =cut
1604
1605 sub _rebless {
1606   my $self = shift;
1607   my $plan = $self->plan;
1608   unless ( $plan ) {
1609     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1610       if $DEBUG;
1611     return $self;
1612   }
1613   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1614   my $class = ref($self). "::$plan";
1615   warn "reblessing $self into $class" if $DEBUG > 1;
1616   eval "use $class;";
1617   die $@ if $@;
1618   bless($self, $class) unless $@;
1619   $self;
1620 }
1621
1622 #fatal fallbacks
1623 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
1624 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
1625
1626 #fallback that return 0 for old legacy packages with no plan
1627 sub calc_remain { 0; }
1628 sub calc_units  { 0; }
1629
1630 #fallback for everything not based on flat.pm
1631 sub recur_temporality { 'upcoming'; }
1632 sub calc_cancel { 0; }
1633
1634 #fallback for everything except bulk.pm
1635 sub hide_svc_detail { 0; }
1636
1637 #fallback for packages that can't/won't summarize usage
1638 sub sum_usage { 0; }
1639
1640 =item recur_cost_permonth CUST_PKG
1641
1642 recur_cost divided by freq (only supported for monthly and longer frequencies)
1643
1644 =cut
1645
1646 sub recur_cost_permonth {
1647   my($self, $cust_pkg) = @_;
1648   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1649   sprintf('%.2f', $self->recur_cost / $self->freq );
1650 }
1651
1652 =item cust_bill_pkg_recur CUST_PKG
1653
1654 Actual recurring charge for the specified customer package from customer's most
1655 recent invoice
1656
1657 =cut
1658
1659 sub cust_bill_pkg_recur {
1660   my($self, $cust_pkg) = @_;
1661   my $cust_bill_pkg = qsearchs({
1662     'table'     => 'cust_bill_pkg',
1663     'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )',
1664     'hashref'   => { 'pkgnum' => $cust_pkg->pkgnum,
1665                      'recur'  => { op=>'>', value=>'0' },
1666                    },
1667     'order_by'  => 'ORDER BY cust_bill._date     DESC,
1668                              cust_bill_pkg.sdate DESC
1669                      LIMIT 1
1670                    ',
1671   }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition";
1672   $cust_bill_pkg->recur;
1673 }
1674
1675 =item unit_setup CUST_PKG
1676
1677 Returns the setup fee for one unit of the package.
1678
1679 =cut
1680
1681 sub unit_setup {
1682   my ($self, $cust_pkg) = @_;
1683   $self->option('setup_fee') || 0;
1684 }
1685
1686 =item setup_margin
1687
1688 unit_setup minus setup_cost
1689
1690 =cut
1691
1692 sub setup_margin {
1693   my $self = shift;
1694   $self->unit_setup(@_) - $self->setup_cost;
1695 }
1696
1697 =item recur_margin_permonth
1698
1699 base_recur_permonth minus recur_cost_permonth
1700
1701 =cut
1702
1703 sub recur_margin_permonth {
1704   my $self = shift;
1705   $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
1706 }
1707
1708 =item format OPTION DATA
1709
1710 Returns data formatted according to the function 'format' described
1711 in the plan info.  Returns DATA if no such function exists.
1712
1713 =cut
1714
1715 sub format {
1716   my ($self, $option, $data) = (shift, shift, shift);
1717   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1718     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1719   }else{
1720     $data;
1721   }
1722 }
1723
1724 =item parse OPTION DATA
1725
1726 Returns data parsed according to the function 'parse' described
1727 in the plan info.  Returns DATA if no such function exists.
1728
1729 =cut
1730
1731 sub parse {
1732   my ($self, $option, $data) = (shift, shift, shift);
1733   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1734     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1735   }else{
1736     $data;
1737   }
1738 }
1739
1740 =back
1741
1742 =cut
1743
1744 =head1 CLASS METHODS
1745
1746 =over 4
1747
1748 =cut
1749
1750 # _upgrade_data
1751 #
1752 # Used by FS::Upgrade to migrate to a new database.
1753
1754 sub _upgrade_data { # class method
1755   my($class, %opts) = @_;
1756
1757   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1758
1759   my @part_pkg = qsearch({
1760     'table'     => 'part_pkg',
1761     'extra_sql' => "WHERE ". join(' OR ',
1762                      'plan IS NULL', "plan = '' ",
1763                    ),
1764   });
1765
1766   foreach my $part_pkg (@part_pkg) {
1767
1768     unless ( $part_pkg->plan ) {
1769       $part_pkg->plan('flat');
1770     }
1771
1772     $part_pkg->replace;
1773
1774   }
1775   # the rest can be done asynchronously
1776 }
1777
1778 sub queueable_upgrade {
1779   # now upgrade to the explicit custom flag
1780
1781   my $search = FS::Cursor->new({
1782     'table'     => 'part_pkg',
1783     'hashref'   => { disabled => 'Y', custom => '' },
1784     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1785   });
1786   my $dbh = dbh;
1787
1788   while (my $part_pkg = $search->fetch) {
1789     my $new = new FS::part_pkg { $part_pkg->hash };
1790     $new->custom('Y');
1791     my $comment = $part_pkg->comment;
1792     $comment =~ s/^\(CUSTOM\) //;
1793     $comment = '(none)' unless $comment =~ /\S/;
1794     $new->comment($comment);
1795
1796     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1797     my $primary = $part_pkg->svcpart;
1798     my $options = { $part_pkg->options };
1799
1800     my $error = $new->replace( $part_pkg,
1801                                'pkg_svc'     => $pkg_svc,
1802                                'primary_svc' => $primary,
1803                                'options'     => $options,
1804                              );
1805     if ($error) {
1806       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1807       $dbh->rollback;
1808     } else {
1809       $dbh->commit;
1810     }
1811   }
1812
1813   # set family_pkgpart on any packages that don't have it
1814   $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
1815   while (my $part_pkg = $search->fetch) {
1816     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
1817     my $error = $part_pkg->SUPER::replace;
1818     if ($error) {
1819       warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
1820       $dbh->rollback;
1821     } else {
1822       $dbh->commit;
1823     }
1824   }
1825
1826   my @part_pkg_option = qsearch('part_pkg_option',
1827     { 'optionname'  => 'unused_credit',
1828       'optionvalue' => 1,
1829     });
1830   foreach my $old_opt (@part_pkg_option) {
1831     my $pkgpart = $old_opt->pkgpart;
1832     my $error = $old_opt->delete;
1833     die $error if $error;
1834
1835     foreach (qw(unused_credit_cancel unused_credit_change)) {
1836       my $new_opt = new FS::part_pkg_option {
1837         'pkgpart'     => $pkgpart,
1838         'optionname'  => $_,
1839         'optionvalue' => 1,
1840       };
1841       $error = $new_opt->insert;
1842       die $error if $error;
1843     }
1844   }
1845
1846   # migrate use_disposition_taqua and use_disposition to disposition_in
1847   @part_pkg_option = qsearch('part_pkg_option',
1848     { 'optionname'  => { op => 'LIKE',
1849                          value => 'use_disposition%',
1850                        },
1851       'optionvalue' => 1,
1852     });
1853   my %newopts = map { $_->pkgpart => $_ } 
1854     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1855   foreach my $old_opt (@part_pkg_option) {
1856         my $pkgpart = $old_opt->pkgpart;
1857         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1858                                                                   : 'ANSWERED';
1859         my $error = $old_opt->delete;
1860         die $error if $error;
1861
1862         if ( exists($newopts{$pkgpart}) ) {
1863             my $opt = $newopts{$pkgpart};
1864             $opt->optionvalue($opt->optionvalue.",$newval");
1865             $error = $opt->replace;
1866             die $error if $error;
1867         } else {
1868             my $new_opt = new FS::part_pkg_option {
1869                 'pkgpart'     => $pkgpart,
1870                 'optionname'  => 'disposition_in',
1871                 'optionvalue' => $newval,
1872               };
1873               $error = $new_opt->insert;
1874               die $error if $error;
1875               $newopts{$pkgpart} = $new_opt;
1876         }
1877   }
1878
1879   # set any package with FCC voice lines to the "VoIP with broadband" category
1880   # for backward compatibility
1881   #
1882   # recover from a bad upgrade bug
1883   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1884   if (!FS::upgrade_journal->is_done($upgrade)) {
1885     my $bad_upgrade = qsearchs('upgrade_journal', 
1886       { upgrade => 'part_pkg_fcc_voip_class' }
1887     );
1888     if ( $bad_upgrade ) {
1889       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1890                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1891       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1892         qsearch({
1893           'select'    => '*',
1894           'table'     => 'h_part_pkg_option',
1895           'hashref'   => {},
1896           'extra_sql' => "$where AND history_action = 'delete'",
1897           'order_by'  => 'ORDER BY history_date ASC',
1898         });
1899       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1900         qsearch({
1901           'select'    => '*',
1902           'table'     => 'h_pkg_svc',
1903           'hashref'   => {},
1904           'extra_sql' => "$where AND history_action = 'replace_old'",
1905           'order_by'  => 'ORDER BY history_date ASC',
1906         });
1907       my %opt;
1908       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1909         my $pkgpart ||= $deleted->pkgpart;
1910         $opt{$pkgpart} ||= {
1911           options => {},
1912           pkg_svc => {},
1913           primary_svc => '',
1914           hidden_svc => {},
1915         };
1916         if ( $deleted->isa('FS::part_pkg_option') ) {
1917           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1918         } else { # pkg_svc
1919           my $svcpart = $deleted->svcpart;
1920           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1921           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1922           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1923         }
1924       }
1925       foreach my $pkgpart (keys %opt) {
1926         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1927         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1928         if ( $error ) {
1929           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1930         }
1931       }
1932     } # $bad_upgrade exists
1933     else { # do the original upgrade, but correctly this time
1934       my @part_pkg = qsearch('part_pkg', {
1935           fcc_ds0s        => { op => '>', value => 0 },
1936           fcc_voip_class  => ''
1937       });
1938       foreach my $part_pkg (@part_pkg) {
1939         $part_pkg->set(fcc_voip_class => 2);
1940         my @pkg_svc = $part_pkg->pkg_svc;
1941         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1942         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1943         my $error = $part_pkg->replace(
1944           $part_pkg->replace_old,
1945           options     => { $part_pkg->options },
1946           pkg_svc     => \%quantity,
1947           hidden_svc  => \%hidden,
1948           primary_svc => ($part_pkg->svcpart || ''),
1949         );
1950         die $error if $error;
1951       }
1952     }
1953     FS::upgrade_journal->set_done($upgrade);
1954   }
1955
1956 }
1957
1958 =item curuser_pkgs_sql
1959
1960 Returns an SQL fragment for searching for packages the current user can
1961 use, either via part_pkg.agentnum directly, or via agent type (see
1962 L<FS::type_pkgs>).
1963
1964 =cut
1965
1966 sub curuser_pkgs_sql {
1967   my $class = shift;
1968
1969   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1970
1971 }
1972
1973 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1974
1975 Returns an SQL fragment for searching for packages the provided agent or agents
1976 can use, either via part_pkg.agentnum directly, or via agent type (see
1977 L<FS::type_pkgs>).
1978
1979 =cut
1980
1981 sub agent_pkgs_sql {
1982   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1983   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1984
1985   $class->_pkgs_sql(@agentnums); #is this why
1986
1987 }
1988
1989 sub _pkgs_sql {
1990   my( $class, @agentnums ) = @_;
1991   my $agentnums = join(',', @agentnums);
1992
1993   "
1994     (
1995       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1996       OR ( agentnum IS NULL
1997            AND EXISTS ( SELECT 1
1998                           FROM type_pkgs
1999                             LEFT JOIN agent_type USING ( typenum )
2000                             LEFT JOIN agent AS typeagent USING ( typenum )
2001                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
2002                             AND typeagent.agentnum IN ($agentnums)
2003                       )
2004          )
2005     )
2006   ";
2007
2008 }
2009
2010 =back
2011
2012 =head1 SUBROUTINES
2013
2014 =over 4
2015
2016 =item plan_info
2017
2018 =cut
2019
2020 #false laziness w/part_export & cdr
2021 my %info;
2022 foreach my $INC ( @INC ) {
2023   warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
2024   foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
2025     warn "attempting to load plan info from $file\n" if $DEBUG;
2026     $file =~ /\/(\w+)\.pm$/ or do {
2027       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
2028       next;
2029     };
2030     my $mod = $1;
2031     my $info = eval "use FS::part_pkg::$mod; ".
2032                     "\\%FS::part_pkg::$mod\::info;";
2033     if ( $@ ) {
2034       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
2035       next;
2036     }
2037     unless ( keys %$info ) {
2038       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
2039       next;
2040     }
2041     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
2042     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
2043     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
2044     #  next;
2045     #}
2046     $info{$mod} = $info;
2047     $info->{'weight'} ||= 0; # quiet warnings
2048   }
2049 }
2050
2051 # copy one level deep to allow replacement of fields and fieldorder
2052 tie %plans, 'Tie::IxHash',
2053   map  { my %infohash = %{ $info{$_} }; 
2054           $_ => \%infohash }
2055   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
2056   keys %info;
2057
2058 # inheritance of plan options
2059 foreach my $name (keys(%info)) {
2060   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
2061     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
2062     delete $plans{$name};
2063     next;
2064   }
2065   my $parents = $info{$name}->{'inherit_fields'} || [];
2066   my (%fields, %field_exists, @fieldorder);
2067   foreach my $parent ($name, @$parents) {
2068     if ( !exists($info{$parent}) ) {
2069       warn "$name tried to inherit from nonexistent '$parent'\n";
2070       next;
2071     }
2072     %fields = ( # avoid replacing existing fields
2073       %{ $info{$parent}->{'fields'} || {} },
2074       %fields
2075     );
2076     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
2077       # avoid duplicates
2078       next if $field_exists{$_};
2079       $field_exists{$_} = 1;
2080       # allow inheritors to remove inherited fields from the fieldorder
2081       push @fieldorder, $_ if !exists($fields{$_}) or
2082                               !exists($fields{$_}->{'disabled'});
2083     }
2084   }
2085   $plans{$name}->{'fields'} = \%fields;
2086   $plans{$name}->{'fieldorder'} = \@fieldorder;
2087 }
2088
2089 sub plan_info {
2090   \%plans;
2091 }
2092
2093
2094 =back
2095
2096 =head1 NEW PLAN CLASSES
2097
2098 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
2099 found in eg/plan_template.pm.  Until then, it is suggested that you use the
2100 other modules in FS/FS/part_pkg/ as a guide.
2101
2102 =head1 BUGS
2103
2104 The delete method is unimplemented.
2105
2106 setup and recur semantics are not yet defined (and are implemented in
2107 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
2108
2109 plandata should go
2110
2111 part_pkg_taxrate is Pg specific
2112
2113 replace should be smarter about managing the related tables (options, pkg_svc)
2114
2115 =head1 SEE ALSO
2116
2117 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
2118 schema.html from the base documentation.
2119
2120 =cut
2121
2122 1;
2123