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