add package def option to show $0 recurring on invoices, RT#9777
[freeside.git] / FS / FS / part_pkg.pm
1 package FS::part_pkg;
2
3 use strict;
4 use vars qw( @ISA %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
5 use Carp qw(carp cluck confess);
6 use Scalar::Util qw( blessed );
7 use Time::Local qw( timelocal_nocheck );
8 use Tie::IxHash;
9 use FS::Conf;
10 use FS::Record qw( qsearch qsearchs dbh dbdef );
11 use FS::pkg_svc;
12 use FS::part_svc;
13 use FS::cust_pkg;
14 use FS::agent_type;
15 use FS::type_pkgs;
16 use FS::part_pkg_option;
17 use FS::pkg_class;
18 use FS::agent;
19 use FS::part_pkg_taxrate;
20 use FS::part_pkg_taxoverride;
21 use FS::part_pkg_taxproduct;
22 use FS::part_pkg_link;
23 use FS::part_pkg_discount;
24
25 @ISA = qw( FS::m2m_Common FS::option_Common );
26 $DEBUG = 0;
27 $setup_hack = 0;
28 $skip_pkg_svc_hack = 0;
29
30 =head1 NAME
31
32 FS::part_pkg - Object methods for part_pkg objects
33
34 =head1 SYNOPSIS
35
36   use FS::part_pkg;
37
38   $record = new FS::part_pkg \%hash
39   $record = new FS::part_pkg { 'column' => 'value' };
40
41   $custom_record = $template_record->clone;
42
43   $error = $record->insert;
44
45   $error = $new_record->replace($old_record);
46
47   $error = $record->delete;
48
49   $error = $record->check;
50
51   @pkg_svc = $record->pkg_svc;
52
53   $svcnum = $record->svcpart;
54   $svcnum = $record->svcpart( 'svc_acct' );
55
56 =head1 DESCRIPTION
57
58 An FS::part_pkg object represents a package definition.  FS::part_pkg
59 inherits from FS::Record.  The following fields are currently supported:
60
61 =over 4
62
63 =item pkgpart - primary key (assigned automatically for new package definitions)
64
65 =item pkg - Text name of this package definition (customer-viewable)
66
67 =item comment - Text name of this package definition (non-customer-viewable)
68
69 =item classnum - Optional package class (see L<FS::pkg_class>)
70
71 =item promo_code - Promotional code
72
73 =item setup - Setup fee expression (deprecated)
74
75 =item freq - Frequency of recurring fee
76
77 =item recur - Recurring fee expression (deprecated)
78
79 =item setuptax - Setup fee tax exempt flag, empty or `Y'
80
81 =item recurtax - Recurring fee tax exempt flag, empty or `Y'
82
83 =item taxclass - Tax class 
84
85 =item plan - Price plan
86
87 =item plandata - Price plan data (deprecated - see L<FS::part_pkg_option> instead)
88
89 =item disabled - Disabled flag, empty or `Y'
90
91 =item custom - Custom flag, empty or `Y'
92
93 =item setup_cost - for cost tracking
94
95 =item recur_cost - for cost tracking
96
97 =item pay_weight - Weight (relative to credit_weight and other package definitions) that controls payment application to specific line items.
98
99 =item credit_weight - Weight (relative to other package definitions) that controls credit application to specific line items.
100
101 =item agentnum - Optional agentnum (see L<FS::agent>)
102
103 =item fcc_ds0s - Optional DS0 equivalency number for FCC form 477
104
105 =back
106
107 =head1 METHODS
108
109 =over 4 
110
111 =item new HASHREF
112
113 Creates a new package definition.  To add the package definition to
114 the database, see L<"insert">.
115
116 =cut
117
118 sub table { 'part_pkg'; }
119
120 =item clone
121
122 An alternate constructor.  Creates a new package definition by duplicating
123 an existing definition.  A new pkgpart is assigned and the custom flag is
124 set to Y.  To add the package definition to the database, see L<"insert">.
125
126 =cut
127
128 sub clone {
129   my $self = shift;
130   my $class = ref($self);
131   my %hash = $self->hash;
132   $hash{'pkgpart'} = '';
133   $hash{'custom'} = 'Y';
134   #new FS::part_pkg ( \%hash ); # ?
135   new $class ( \%hash ); # ?
136 }
137
138 =item insert [ , OPTION => VALUE ... ]
139
140 Adds this package definition to the database.  If there is an error,
141 returns the error, otherwise returns false.
142
143 Currently available options are: I<pkg_svc>, I<primary_svc>, I<cust_pkg>, 
144 I<custnum_ref> and I<options>.
145
146 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
147 values, appropriate FS::pkg_svc records will be inserted.  I<hidden_svc> can 
148 be set to a hashref of svcparts and flag values ('Y' or '') to set the 
149 'hidden' field in these records.
150
151 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
152 FS::pkg_svc record will be updated.
153
154 If I<cust_pkg> is set to a pkgnum of a FS::cust_pkg record (or the FS::cust_pkg
155 record itself), the object will be updated to point to this package definition.
156
157 In conjunction with I<cust_pkg>, if I<custnum_ref> is set to a scalar reference,
158 the scalar will be updated with the custnum value from the cust_pkg record.
159
160 If I<tax_overrides> is set to a hashref with usage classes as keys and comma
161 separated tax class numbers as values, appropriate FS::part_pkg_taxoverride
162 records will be inserted.
163
164 If I<options> is set to a hashref of options, appropriate FS::part_pkg_option
165 records will be inserted.
166
167 =cut
168
169 sub insert {
170   my $self = shift;
171   my %options = @_;
172   warn "FS::part_pkg::insert called on $self with options ".
173        join(', ', map "$_=>$options{$_}", keys %options)
174     if $DEBUG;
175
176   local $SIG{HUP} = 'IGNORE';
177   local $SIG{INT} = 'IGNORE';
178   local $SIG{QUIT} = 'IGNORE';
179   local $SIG{TERM} = 'IGNORE';
180   local $SIG{TSTP} = 'IGNORE';
181   local $SIG{PIPE} = 'IGNORE';
182
183   my $oldAutoCommit = $FS::UID::AutoCommit;
184   local $FS::UID::AutoCommit = 0;
185   my $dbh = dbh;
186
187   warn "  inserting part_pkg record" if $DEBUG;
188   my $error = $self->SUPER::insert( $options{options} );
189   if ( $error ) {
190     $dbh->rollback if $oldAutoCommit;
191     return $error;
192   }
193
194   my $conf = new FS::Conf;
195   if ( $conf->exists('agent_defaultpkg') ) {
196     warn "  agent_defaultpkg set; allowing all agents to purchase package"
197       if $DEBUG;
198     foreach my $agent_type ( qsearch('agent_type', {} ) ) {
199       my $type_pkgs = new FS::type_pkgs({
200         'typenum' => $agent_type->typenum,
201         'pkgpart' => $self->pkgpart,
202       });
203       my $error = $type_pkgs->insert;
204       if ( $error ) {
205         $dbh->rollback if $oldAutoCommit;
206         return $error;
207       }
208     }
209   }
210
211   warn "  inserting part_pkg_taxoverride records" if $DEBUG;
212   my %overrides = %{ $options{'tax_overrides'} || {} };
213   foreach my $usage_class ( keys %overrides ) {
214     my $override =
215       ( exists($overrides{$usage_class}) && defined($overrides{$usage_class}) )
216         ? $overrides{$usage_class}
217         : '';
218     my @overrides = (grep "$_", split(',', $override) );
219     my $error = $self->process_m2m (
220                   'link_table'   => 'part_pkg_taxoverride',
221                   'target_table' => 'tax_class',
222                   'hashref'      => { 'usage_class' => $usage_class },
223                   'params'       => \@overrides,
224                 );
225     if ( $error ) {
226       $dbh->rollback if $oldAutoCommit;
227       return $error;
228     }
229   }
230
231   unless ( $skip_pkg_svc_hack ) {
232
233     warn "  inserting pkg_svc records" if $DEBUG;
234     my $pkg_svc = $options{'pkg_svc'} || {};
235     my $hidden_svc = $options{'hidden_svc'} || {};
236     foreach my $part_svc ( qsearch('part_svc', {} ) ) {
237       my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
238       my $primary_svc =
239         ( $options{'primary_svc'} && $options{'primary_svc'}==$part_svc->svcpart )
240           ? 'Y'
241           : '';
242
243       my $pkg_svc = new FS::pkg_svc( {
244         'pkgpart'     => $self->pkgpart,
245         'svcpart'     => $part_svc->svcpart,
246         'quantity'    => $quantity, 
247         'primary_svc' => $primary_svc,
248         'hidden'      => $hidden_svc->{$part_svc->svcpart},
249       } );
250       my $error = $pkg_svc->insert;
251       if ( $error ) {
252         $dbh->rollback if $oldAutoCommit;
253         return $error;
254       }
255     }
256
257   }
258
259   if ( $options{'cust_pkg'} ) {
260     warn "  updating cust_pkg record " if $DEBUG;
261     my $old_cust_pkg =
262       ref($options{'cust_pkg'})
263         ? $options{'cust_pkg'}
264         : qsearchs('cust_pkg', { pkgnum => $options{'cust_pkg'} } );
265     ${ $options{'custnum_ref'} } = $old_cust_pkg->custnum
266       if $options{'custnum_ref'};
267     my %hash = $old_cust_pkg->hash;
268     $hash{'pkgpart'} = $self->pkgpart,
269     my $new_cust_pkg = new FS::cust_pkg \%hash;
270     local($FS::cust_pkg::disable_agentcheck) = 1;
271     my $error = $new_cust_pkg->replace($old_cust_pkg);
272     if ( $error ) {
273       $dbh->rollback if $oldAutoCommit;
274       return "Error modifying cust_pkg record: $error";
275     }
276   }
277
278   warn "  commiting transaction" if $DEBUG;
279   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
280
281   '';
282 }
283
284 =item delete
285
286 Currently unimplemented.
287
288 =cut
289
290 sub delete {
291   return "Can't (yet?) delete package definitions.";
292 # check & make sure the pkgpart isn't in cust_pkg or type_pkgs?
293 }
294
295 =item replace OLD_RECORD [ , OPTION => VALUE ... ]
296
297 Replaces OLD_RECORD with this one in the database.  If there is an error,
298 returns the error, otherwise returns false.
299
300 Currently available options are: I<pkg_svc>, I<hidden_svc>, I<primary_svc> 
301 and I<options>
302
303 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
304 values, the appropriate FS::pkg_svc records will be replaced.  I<hidden_svc>
305 can be set to a hashref of svcparts and flag values ('Y' or '') to set the 
306 'hidden' field in these records.
307
308 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
309 FS::pkg_svc record will be updated.
310
311 If I<options> is set to a hashref, the appropriate FS::part_pkg_option records
312 will be replaced.
313
314 =cut
315
316 sub replace {
317   my $new = shift;
318
319   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
320               ? shift
321               : $new->replace_old;
322
323   my $options = 
324     ( ref($_[0]) eq 'HASH' )
325       ? shift
326       : { @_ };
327
328   $options->{options} = {} unless defined($options->{options});
329
330   warn "FS::part_pkg::replace called on $new to replace $old with options".
331        join(', ', map "$_ => ". $options->{$_}, keys %$options)
332     if $DEBUG;
333
334   local $SIG{HUP} = 'IGNORE';
335   local $SIG{INT} = 'IGNORE';
336   local $SIG{QUIT} = 'IGNORE';
337   local $SIG{TERM} = 'IGNORE';
338   local $SIG{TSTP} = 'IGNORE';
339   local $SIG{PIPE} = 'IGNORE';
340
341   my $oldAutoCommit = $FS::UID::AutoCommit;
342   local $FS::UID::AutoCommit = 0;
343   my $dbh = dbh;
344
345   #plandata shit stays in replace for upgrades until after 2.0 (or edit
346   #_upgrade_data)
347   warn "  saving legacy plandata" if $DEBUG;
348   my $plandata = $new->get('plandata');
349   $new->set('plandata', '');
350
351   warn "  deleting old part_pkg_option records" if $DEBUG;
352   foreach my $part_pkg_option ( $old->part_pkg_option ) {
353     my $error = $part_pkg_option->delete;
354     if ( $error ) {
355       $dbh->rollback if $oldAutoCommit;
356       return $error;
357     }
358   }
359
360   warn "  replacing part_pkg record" if $DEBUG;
361   my $error = $new->SUPER::replace($old, $options->{options} );
362   if ( $error ) {
363     $dbh->rollback if $oldAutoCommit;
364     return $error;
365   }
366
367   warn "  inserting part_pkg_option records for plandata: $plandata|" if $DEBUG;
368   foreach my $part_pkg_option ( 
369     map { /^(\w+)=(.*)$/ or do { $dbh->rollback if $oldAutoCommit;
370                                  return "illegal plandata: $plandata";
371                                };
372           new FS::part_pkg_option {
373             'pkgpart'     => $new->pkgpart,
374             'optionname'  => $1,
375             'optionvalue' => $2,
376           };
377         }
378     split("\n", $plandata)
379   ) {
380     my $error = $part_pkg_option->insert;
381     if ( $error ) {
382       $dbh->rollback if $oldAutoCommit;
383       return $error;
384     }
385   }
386
387   warn "  replacing pkg_svc records" if $DEBUG;
388   my $pkg_svc = $options->{'pkg_svc'} || {};
389   my $hidden_svc = $options->{'hidden_svc'} || {};
390   foreach my $part_svc ( qsearch('part_svc', {} ) ) {
391     my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
392     my $hidden = $hidden_svc->{$part_svc->svcpart} || '';
393     my $primary_svc =
394       ( defined($options->{'primary_svc'}) && $options->{'primary_svc'}
395         && $options->{'primary_svc'} == $part_svc->svcpart
396       )
397         ? 'Y'
398         : '';
399
400     my $old_pkg_svc = qsearchs('pkg_svc', {
401         'pkgpart' => $old->pkgpart,
402         'svcpart' => $part_svc->svcpart,
403       }
404     );
405     my $old_quantity = 0;
406     my $old_primary_svc = '';
407     my $old_hidden = '';
408     if ( $old_pkg_svc ) {
409       $old_quantity = $old_pkg_svc->quantity;
410       $old_primary_svc = $old_pkg_svc->primary_svc 
411         if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed?
412       $old_hidden = $old_pkg_svc->hidden;
413     }
414  
415     next unless $old_quantity != $quantity || 
416                 $old_primary_svc ne $primary_svc ||
417                 $old_hidden ne $hidden;
418   
419     my $new_pkg_svc = new FS::pkg_svc( {
420       'pkgsvcnum'   => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
421       'pkgpart'     => $new->pkgpart,
422       'svcpart'     => $part_svc->svcpart,
423       'quantity'    => $quantity, 
424       'primary_svc' => $primary_svc,
425       'hidden'      => $hidden,
426     } );
427     my $error = $old_pkg_svc
428                   ? $new_pkg_svc->replace($old_pkg_svc)
429                   : $new_pkg_svc->insert;
430     if ( $error ) {
431       $dbh->rollback if $oldAutoCommit;
432       return $error;
433     }
434   }
435
436   warn "  commiting transaction" if $DEBUG;
437   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
438   '';
439 }
440
441 =item check
442
443 Checks all fields to make sure this is a valid package definition.  If
444 there is an error, returns the error, otherwise returns false.  Called by the
445 insert and replace methods.
446
447 =cut
448
449 sub check {
450   my $self = shift;
451   warn "FS::part_pkg::check called on $self" if $DEBUG;
452
453   for (qw(setup recur plandata)) {
454     #$self->set($_=>0) if $self->get($_) =~ /^\s*$/; }
455     return "Use of $_ field is deprecated; set a plan and options: ".
456            $self->get($_)
457       if length($self->get($_));
458     $self->set($_, '');
459   }
460
461   if ( $self->dbdef_table->column('freq')->type =~ /(int)/i ) {
462     my $error = $self->ut_number('freq');
463     return $error if $error;
464   } else {
465     $self->freq =~ /^(\d+[hdw]?)$/
466       or return "Illegal or empty freq: ". $self->freq;
467     $self->freq($1);
468   }
469
470   my @null_agentnum_right = ( 'Edit global package definitions' );
471   push @null_agentnum_right, 'One-time charge'
472     if $self->freq =~ /^0/;
473   push @null_agentnum_right, 'Customize customer package'
474     if $self->disabled eq 'Y'; #good enough
475
476   my $error = $self->ut_numbern('pkgpart')
477     || $self->ut_text('pkg')
478     || $self->ut_text('comment')
479     || $self->ut_textn('promo_code')
480     || $self->ut_alphan('plan')
481     || $self->ut_enum('setuptax', [ '', 'Y' ] )
482     || $self->ut_enum('recurtax', [ '', 'Y' ] )
483     || $self->ut_textn('taxclass')
484     || $self->ut_enum('disabled', [ '', 'Y' ] )
485     || $self->ut_enum('custom', [ '', 'Y' ] )
486     || $self->ut_enum('no_auto', [ '', 'Y' ])
487     || $self->ut_enum('recur_show_zero', [ '', 'Y' ])
488     #|| $self->ut_moneyn('setup_cost')
489     #|| $self->ut_moneyn('recur_cost')
490     || $self->ut_floatn('setup_cost')
491     || $self->ut_floatn('recur_cost')
492     || $self->ut_floatn('pay_weight')
493     || $self->ut_floatn('credit_weight')
494     || $self->ut_numbern('taxproductnum')
495     || $self->ut_foreign_keyn('classnum',       'pkg_class', 'classnum')
496     || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum')
497     || $self->ut_foreign_keyn('taxproductnum',
498                               'part_pkg_taxproduct',
499                               'taxproductnum'
500                              )
501     || ( $setup_hack
502            ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' )
503            : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right)
504        )
505     || $self->ut_numbern('fcc_ds0s')
506     || $self->SUPER::check
507   ;
508   return $error if $error;
509
510   return 'Unknown plan '. $self->plan
511     unless exists($plans{$self->plan});
512
513   my $conf = new FS::Conf;
514   return 'Taxclass is required'
515     if ! $self->taxclass && $conf->exists('require_taxclasses');
516
517   '';
518 }
519
520 =item pkg_comment [ OPTION => VALUE... ]
521
522 Returns an (internal) string representing this package.  Currently,
523 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
524 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
525 custom is Y.
526
527 If the option nopkgpart is true then the "pkgpart: ' is omitted.
528
529 =cut
530
531 sub pkg_comment {
532   my $self = shift;
533   my %opt = @_;
534
535   #$self->pkg. ' - '. $self->comment;
536   #$self->pkg. ' ('. $self->comment. ')';
537   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
538   $pre. $self->pkg. ' - '. $self->custom_comment;
539 }
540
541 sub price_info { # safety, in case a part_pkg hasn't defined price_info
542     '';
543 }
544
545 sub custom_comment {
546   my $self = shift;
547   ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info;
548 }
549
550 =item pkg_class
551
552 Returns the package class, as an FS::pkg_class object, or the empty string
553 if there is no package class.
554
555 =cut
556
557 sub pkg_class {
558   my $self = shift;
559   if ( $self->classnum ) {
560     qsearchs('pkg_class', { 'classnum' => $self->classnum } );
561   } else {
562     return '';
563   }
564 }
565
566 =item addon_pkg_class
567
568 Returns the add-on package class, as an FS::pkg_class object, or the empty
569 string if there is no add-on package class.
570
571 =cut
572
573 sub addon_pkg_class {
574   my $self = shift;
575   if ( $self->addon_classnum ) {
576     qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } );
577   } else {
578     return '';
579   }
580 }
581
582 =item categoryname 
583
584 Returns the package category name, or the empty string if there is no package
585 category.
586
587 =cut
588
589 sub categoryname {
590   my $self = shift;
591   my $pkg_class = $self->pkg_class;
592   $pkg_class
593     ? $pkg_class->categoryname
594     : '';
595 }
596
597 =item classname 
598
599 Returns the package class name, or the empty string if there is no package
600 class.
601
602 =cut
603
604 sub classname {
605   my $self = shift;
606   my $pkg_class = $self->pkg_class;
607   $pkg_class
608     ? $pkg_class->classname
609     : '';
610 }
611
612 =item addon_classname 
613
614 Returns the add-on package class name, or the empty string if there is no
615 add-on package class.
616
617 =cut
618
619 sub addon_classname {
620   my $self = shift;
621   my $pkg_class = $self->addon_pkg_class;
622   $pkg_class
623     ? $pkg_class->classname
624     : '';
625 }
626
627 =item agent 
628
629 Returns the associated agent for this event, if any, as an FS::agent object.
630
631 =cut
632
633 sub agent {
634   my $self = shift;
635   qsearchs('agent', { 'agentnum' => $self->agentnum } );
636 }
637
638 =item pkg_svc [ HASHREF | OPTION => VALUE ]
639
640 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
641 definition (with non-zero quantity).
642
643 One option is available, I<disable_linked>.  If set true it will return the
644 services for this package definition alone, omitting services from any add-on
645 packages.
646
647 =cut
648
649 =item type_pkgs
650
651 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
652 definition.
653
654 =cut
655
656 sub type_pkgs {
657   my $self = shift;
658   qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
659 }
660
661 sub pkg_svc {
662   my $self = shift;
663
664 #  #sort { $b->primary cmp $a->primary } 
665 #    grep { $_->quantity }
666 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
667
668   my $opt = ref($_[0]) ? $_[0] : { @_ };
669   my %pkg_svc = map  { $_->svcpart => $_ }
670                 grep { $_->quantity }
671                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
672
673   unless ( $opt->{disable_linked} ) {
674     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
675       my @pkg_svc = grep { $_->quantity }
676                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
677       foreach my $pkg_svc ( @pkg_svc ) {
678         if ( $pkg_svc{$pkg_svc->svcpart} ) {
679           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
680           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
681         } else {
682           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
683         }
684       }
685     }
686   }
687
688   values(%pkg_svc);
689
690 }
691
692 =item svcpart [ SVCDB ]
693
694 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
695 associated with this package definition (see L<FS::pkg_svc>).  Returns
696 false if there not a primary service definition or exactly one service
697 definition with quantity 1, or if SVCDB is specified and does not match the
698 svcdb of the service definition.  SVCDB can be specified as a scalar table
699 name, such as 'svc_acct', or as an arrayref of possible table names.
700
701 =cut
702
703 sub svcpart {
704   my $pkg_svc = shift->_primary_pkg_svc(@_);
705   $pkg_svc ? $pkg_svc->svcpart : '';
706 }
707
708 =item part_svc [ SVCDB ]
709
710 Like the B<svcpart> method, but returns the FS::part_svc object (see
711 L<FS::part_svc>).
712
713 =cut
714
715 sub part_svc {
716   my $pkg_svc = shift->_primary_pkg_svc(@_);
717   $pkg_svc ? $pkg_svc->part_svc : '';
718 }
719
720 sub _primary_pkg_svc {
721   my $self = shift;
722
723   my $svcdb = scalar(@_) ? shift : [];
724   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
725   my %svcdb = map { $_=>1 } @$svcdb;
726
727   my @svcdb_pkg_svc =
728     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
729          $self->pkg_svc;
730
731   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
732   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
733     unless @pkg_svc;
734   return '' if scalar(@pkg_svc) != 1;
735   $pkg_svc[0];
736 }
737
738 =item svcpart_unique_svcdb SVCDB
739
740 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
741 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
742 false if there not a primary service definition for SVCDB or there are multiple
743 service definitions for SVCDB.
744
745 =cut
746
747 sub svcpart_unique_svcdb {
748   my( $self, $svcdb ) = @_;
749   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
750   return '' if scalar(@svcdb_pkg_svc) != 1;
751   $svcdb_pkg_svc[0]->svcpart;
752 }
753
754 =item payby
755
756 Returns a list of the acceptable payment types for this package.  Eventually
757 this should come out of a database table and be editable, but currently has the
758 following logic instead:
759
760 If the package is free, the single item B<BILL> is
761 returned, otherwise, the single item B<CARD> is returned.
762
763 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
764
765 =cut
766
767 sub payby {
768   my $self = shift;
769   if ( $self->is_free ) {
770     ( 'BILL' );
771   } else {
772     ( 'CARD' );
773   }
774 }
775
776 =item is_free
777
778 Returns true if this package is free.  
779
780 =cut
781
782 sub is_free {
783   my $self = shift;
784   unless ( $self->plan ) {
785     $self->setup =~ /^\s*0+(\.0*)?\s*$/
786       && $self->recur =~ /^\s*0+(\.0*)?\s*$/;
787   } elsif ( $self->can('is_free_options') ) {
788     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
789          map { $self->option($_) } 
790              $self->is_free_options;
791   } else {
792     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
793          "provides neither is_free_options nor is_free method; returning false";
794     0;
795   }
796 }
797
798 sub can_discount { 0; }
799
800 sub freqs_href {
801   # moved to FS::Misc to make this accessible to other packages
802   # at initialization
803   FS::Misc::pkg_freqs();
804 }
805
806 =item freq_pretty
807
808 Returns an english representation of the I<freq> field, such as "monthly",
809 "weekly", "semi-annually", etc.
810
811 =cut
812
813 sub freq_pretty {
814   my $self = shift;
815   my $freq = $self->freq;
816
817   #my $freqs_href = $self->freqs_href;
818   my $freqs_href = freqs_href();
819
820   if ( exists($freqs_href->{$freq}) ) {
821     $freqs_href->{$freq};
822   } else {
823     my $interval = 'month';
824     if ( $freq =~ /^(\d+)([hdw])$/ ) {
825       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
826       $interval = $interval{$2};
827     }
828     if ( $1 == 1 ) {
829       "every $interval";
830     } else {
831       "every $freq ${interval}s";
832     }
833   }
834 }
835
836 =item add_freq TIMESTAMP [ FREQ ]
837
838 Adds a billing period of some frequency to the provided timestamp and 
839 returns the resulting timestamp, or -1 if the frequency could not be 
840 parsed (shouldn't happen).  By default, the frequency of this package 
841 will be used; to override this, pass a different frequency as a second 
842 argument.
843
844 =cut
845
846 sub add_freq {
847   my( $self, $date, $freq ) = @_;
848   $freq = $self->freq unless $freq;
849
850   #change this bit to use Date::Manip? CAREFUL with timezones (see
851   # mailing list archive)
852   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
853
854   if ( $freq =~ /^\d+$/ ) {
855     $mon += $freq;
856     until ( $mon < 12 ) { $mon -= 12; $year++; }
857   } elsif ( $freq =~ /^(\d+)w$/ ) {
858     my $weeks = $1;
859     $mday += $weeks * 7;
860   } elsif ( $freq =~ /^(\d+)d$/ ) {
861     my $days = $1;
862     $mday += $days;
863   } elsif ( $freq =~ /^(\d+)h$/ ) {
864     my $hours = $1;
865     $hour += $hours;
866   } else {
867     return -1;
868   }
869
870   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
871 }
872
873 =item plandata
874
875 For backwards compatibility, returns the plandata field as well as all options
876 from FS::part_pkg_option.
877
878 =cut
879
880 sub plandata {
881   my $self = shift;
882   carp "plandata is deprecated";
883   if ( @_ ) {
884     $self->SUPER::plandata(@_);
885   } else {
886     my $plandata = $self->get('plandata');
887     my %options = $self->options;
888     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
889     $plandata;
890   }
891 }
892
893 =item part_pkg_option
894
895 Returns all options as FS::part_pkg_option objects (see
896 L<FS::part_pkg_option>).
897
898 =cut
899
900 sub part_pkg_option {
901   my $self = shift;
902   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
903 }
904
905 =item options 
906
907 Returns a list of option names and values suitable for assigning to a hash.
908
909 =cut
910
911 sub options {
912   my $self = shift;
913   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
914 }
915
916 =item option OPTIONNAME [ QUIET ]
917
918 Returns the option value for the given name, or the empty string.  If a true
919 value is passed as the second argument, warnings about missing the option
920 will be suppressed.
921
922 =cut
923
924 sub option {
925   my( $self, $opt, $ornull ) = @_;
926   my $part_pkg_option =
927     qsearchs('part_pkg_option', {
928       pkgpart    => $self->pkgpart,
929       optionname => $opt,
930   } );
931   return $part_pkg_option->optionvalue if $part_pkg_option;
932   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
933                      split("\n", $self->get('plandata') );
934   return $plandata{$opt} if exists $plandata{$opt};
935   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
936         "not found in options or plandata!\n"
937     unless $ornull;
938   '';
939 }
940
941 =item bill_part_pkg_link
942
943 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
944
945 =cut
946
947 sub bill_part_pkg_link {
948   shift->_part_pkg_link('bill', @_);
949 }
950
951 =item svc_part_pkg_link
952
953 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
954
955 =cut
956
957 sub svc_part_pkg_link {
958   shift->_part_pkg_link('svc', @_);
959 }
960
961 sub _part_pkg_link {
962   my( $self, $type ) = @_;
963   qsearch({ table    => 'part_pkg_link',
964             hashref  => { 'src_pkgpart' => $self->pkgpart,
965                           'link_type'   => $type,
966                           #protection against infinite recursive links
967                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
968                         },
969             order_by => "ORDER BY hidden",
970          });
971 }
972
973 sub self_and_bill_linked {
974   shift->_self_and_linked('bill', @_);
975 }
976
977 sub _self_and_linked {
978   my( $self, $type, $hidden ) = @_;
979   $hidden ||= '';
980
981   my @result = ();
982   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
983                      $self->_part_pkg_link($type) ) )
984   {
985     $_->hidden($hidden) if $hidden;
986     push @result, $_;
987   }
988
989   (@result);
990 }
991
992 =item part_pkg_taxoverride [ CLASS ]
993
994 Returns all associated FS::part_pkg_taxoverride objects (see
995 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
996 of class CLASS if defined.  Class may be one of 'setup', 'recur',
997 the empty string (default), or a usage class number (see L<FS::usage_class>).
998 When a class is specified, the empty string class (default) is returned
999 if no more specific values exist.
1000
1001 =cut
1002
1003 sub part_pkg_taxoverride {
1004   my $self = shift;
1005   my $class = shift;
1006
1007   my $hashref = { 'pkgpart' => $self->pkgpart };
1008   $hashref->{'usage_class'} = $class if defined($class);
1009   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1010
1011   unless ( scalar(@overrides) || !defined($class) || !$class ){
1012     $hashref->{'usage_class'} = '';
1013     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1014   }
1015
1016   @overrides;
1017 }
1018
1019 =item has_taxproduct
1020
1021 Returns true if this package has any taxproduct associated with it.  
1022
1023 =cut
1024
1025 sub has_taxproduct {
1026   my $self = shift;
1027
1028   $self->taxproductnum ||
1029   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1030           keys %{ {$self->options} }
1031   )
1032
1033 }
1034
1035
1036 =item taxproduct [ CLASS ]
1037
1038 Returns the associated tax product for this package definition (see
1039 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1040 the usage classnum (see L<FS::usage_class>).  Returns the default
1041 tax product for this record if the more specific CLASS value does
1042 not exist.
1043
1044 =cut
1045
1046 sub taxproduct {
1047   my $self = shift;
1048   my $class = shift;
1049
1050   my $part_pkg_taxproduct;
1051
1052   my $taxproductnum = $self->taxproductnum;
1053   if ($class) { 
1054     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1055     $taxproductnum = $class_taxproductnum
1056       if $class_taxproductnum
1057   }
1058   
1059   $part_pkg_taxproduct =
1060     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1061
1062   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1063     $taxproductnum = $self->taxproductnum;
1064     $part_pkg_taxproduct =
1065       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1066   }
1067
1068   $part_pkg_taxproduct;
1069 }
1070
1071 =item taxproduct_description [ CLASS ]
1072
1073 Returns the description of the associated tax product for this package
1074 definition (see L<FS::part_pkg_taxproduct>).
1075
1076 =cut
1077
1078 sub taxproduct_description {
1079   my $self = shift;
1080   my $part_pkg_taxproduct = $self->taxproduct(@_);
1081   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1082 }
1083
1084 =item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
1085
1086 Returns the package to taxrate m2m records for this package in the location
1087 specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
1088 CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
1089 (see L<FS::usage_class>).
1090
1091 =cut
1092
1093 sub _expand_cch_taxproductnum {
1094   my $self = shift;
1095   my $class = shift;
1096   my $part_pkg_taxproduct = $self->taxproduct($class);
1097
1098   my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
1099                          ? ( split ':', $part_pkg_taxproduct->taxproduct )
1100                          : ()
1101                      );
1102   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
1103   my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
1104                       OR taxproduct = '$a:$b:$c:'
1105                       OR taxproduct = '$a:$b:".":$d'
1106                       OR taxproduct = '$a:$b:".":' )";
1107   map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
1108                                        'hashref'   => { 'data_vendor'=>'cch' },
1109                                        'extra_sql' => $extra_sql,
1110                                    } );
1111                                      
1112 }
1113
1114 sub part_pkg_taxrate {
1115   my $self = shift;
1116   my ($data_vendor, $geocode, $class) = @_;
1117
1118   my $dbh = dbh;
1119   my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
1120                   dbh->quote($data_vendor);
1121   
1122   # CCH oddness in m2m
1123   $extra_sql .= ' AND ('.
1124     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
1125                  qw(10 5 2)
1126         ).
1127     ')';
1128   # much more CCH oddness in m2m -- this is kludgy
1129   my @tpnums = $self->_expand_cch_taxproductnum($class);
1130   if (scalar(@tpnums)) {
1131     $extra_sql .= ' AND ('.
1132                             join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
1133                        ')';
1134   } else {
1135     $extra_sql .= ' AND ( 0 = 1 )';
1136   }
1137
1138   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
1139   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
1140   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
1141
1142   # should qsearch preface columns with the table to facilitate joins?
1143   qsearch( { 'table'     => 'part_pkg_taxrate',
1144              'select'    => $select,
1145              'hashref'   => { # 'data_vendor'   => $data_vendor,
1146                               # 'taxproductnum' => $self->taxproductnum,
1147                             },
1148              'addl_from' => $addl_from,
1149              'extra_sql' => $extra_sql,
1150              'order_by'  => $order_by,
1151          } );
1152 }
1153
1154 =item part_pkg_discount
1155
1156 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1157 for this package.
1158
1159 =cut
1160
1161 sub part_pkg_discount {
1162   my $self = shift;
1163   qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
1164 }
1165
1166 =item _rebless
1167
1168 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1169 PLAN is the object's I<plan> field.  There should be better docs
1170 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1171
1172 =cut
1173
1174 sub _rebless {
1175   my $self = shift;
1176   my $plan = $self->plan;
1177   unless ( $plan ) {
1178     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1179       if $DEBUG;
1180     return $self;
1181   }
1182   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1183   my $class = ref($self). "::$plan";
1184   warn "reblessing $self into $class" if $DEBUG;
1185   eval "use $class;";
1186   die $@ if $@;
1187   bless($self, $class) unless $@;
1188   $self;
1189 }
1190
1191 #fallbacks that eval the setup and recur fields, for backwards compat
1192
1193 sub calc_setup {
1194   my $self = shift;
1195   warn 'no price plan class for '. $self->plan. ", eval-ing setup\n";
1196   $self->_calc_eval('setup', @_);
1197 }
1198
1199 sub calc_recur {
1200   my $self = shift;
1201   warn 'no price plan class for '. $self->plan. ", eval-ing recur\n";
1202   $self->_calc_eval('recur', @_);
1203 }
1204
1205 use vars qw( $sdate @details );
1206 sub _calc_eval {
1207   #my( $self, $field, $cust_pkg ) = @_;
1208   my( $self, $field, $cust_pkg, $sdateref, $detailsref ) = @_;
1209   *sdate = $sdateref;
1210   *details = $detailsref;
1211   $self->$field() =~ /^(.*)$/
1212     or die "Illegal $field (pkgpart ". $self->pkgpart. '): '.
1213             $self->$field(). "\n";
1214   my $prog = $1;
1215   return 0 if $prog =~ /^\s*$/;
1216   my $value = eval $prog;
1217   die $@ if $@;
1218   $value;
1219 }
1220
1221 #fallback that return 0 for old legacy packages with no plan
1222
1223 sub calc_remain { 0; }
1224 sub calc_cancel { 0; }
1225 sub calc_units  { 0; }
1226
1227 #fallback for everything except bulk.pm
1228 sub hide_svc_detail { 0; }
1229
1230 =item recur_cost_permonth CUST_PKG
1231
1232 recur_cost divided by freq (only supported for monthly and longer frequencies)
1233
1234 =cut
1235
1236 sub recur_cost_permonth {
1237   my($self, $cust_pkg) = @_;
1238   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1239   sprintf('%.2f', $self->recur_cost / $self->freq );
1240 }
1241
1242 =item format OPTION DATA
1243
1244 Returns data formatted according to the function 'format' described
1245 in the plan info.  Returns DATA if no such function exists.
1246
1247 =cut
1248
1249 sub format {
1250   my ($self, $option, $data) = (shift, shift, shift);
1251   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1252     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1253   }else{
1254     $data;
1255   }
1256 }
1257
1258 =item parse OPTION DATA
1259
1260 Returns data parsed according to the function 'parse' described
1261 in the plan info.  Returns DATA if no such function exists.
1262
1263 =cut
1264
1265 sub parse {
1266   my ($self, $option, $data) = (shift, shift, shift);
1267   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1268     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1269   }else{
1270     $data;
1271   }
1272 }
1273
1274 =back
1275
1276 =cut
1277
1278 =head1 CLASS METHODS
1279
1280 =over 4
1281
1282 =cut
1283
1284 # _upgrade_data
1285 #
1286 # Used by FS::Upgrade to migrate to a new database.
1287
1288 sub _upgrade_data { # class method
1289   my($class, %opts) = @_;
1290
1291   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1292
1293   my @part_pkg = qsearch({
1294     'table'     => 'part_pkg',
1295     'extra_sql' => "WHERE ". join(' OR ',
1296                      ( map "($_ IS NOT NULL AND $_ != '' )",
1297                            qw( plandata setup recur ) ),
1298                      'plan IS NULL', "plan = '' ",
1299                    ),
1300   });
1301
1302   foreach my $part_pkg (@part_pkg) {
1303
1304     unless ( $part_pkg->plan ) {
1305       $part_pkg->plan('flat');
1306     }
1307
1308     if ( length($part_pkg->option('setup_fee')) == 0 
1309          && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) {
1310
1311       my $opt = new FS::part_pkg_option {
1312         'pkgpart'     => $part_pkg->pkgpart,
1313         'optionname'  => 'setup_fee',
1314         'optionvalue' => $1,
1315       };
1316       my $error = $opt->insert;
1317       die $error if $error;
1318
1319
1320       #} else {
1321       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1322       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1323     }
1324     $part_pkg->setup('');
1325
1326     if ( length($part_pkg->option('recur_fee')) == 0
1327          && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) {
1328
1329         my $opt = new FS::part_pkg_option {
1330           'pkgpart'     => $part_pkg->pkgpart,
1331           'optionname'  => 'recur_fee',
1332           'optionvalue' => $1,
1333         };
1334         my $error = $opt->insert;
1335         die $error if $error;
1336
1337
1338       #} else {
1339       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1340       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1341     }
1342     $part_pkg->recur('');
1343
1344     $part_pkg->replace; #this should take care of plandata, right?
1345
1346   }
1347
1348   # now upgrade to the explicit custom flag
1349
1350   @part_pkg = qsearch({
1351     'table'     => 'part_pkg',
1352     'hashref'   => { disabled => 'Y', custom => '' },
1353     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1354   });
1355
1356   foreach my $part_pkg (@part_pkg) {
1357     my $new = new FS::part_pkg { $part_pkg->hash };
1358     $new->custom('Y');
1359     my $comment = $part_pkg->comment;
1360     $comment =~ s/^\(CUSTOM\) //;
1361     $comment = '(none)' unless $comment =~ /\S/;
1362     $new->comment($comment);
1363
1364     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1365     my $primary = $part_pkg->svcpart;
1366     my $options = { $part_pkg->options };
1367
1368     my $error = $new->replace( $part_pkg,
1369                                'pkg_svc'     => $pkg_svc,
1370                                'primary_svc' => $primary,
1371                                'options'     => $options,
1372                              );
1373     die $error if $error;
1374   }
1375
1376   my @part_pkg_option = qsearch('part_pkg_option',
1377     { 'optionname'  => 'unused_credit',
1378       'optionvalue' => 1,
1379     });
1380   foreach my $old_opt (@part_pkg_option) {
1381     my $pkgpart = $old_opt->pkgpart;
1382     my $error = $old_opt->delete;
1383     die $error if $error;
1384
1385     foreach (qw(unused_credit_cancel unused_credit_change)) {
1386       my $new_opt = new FS::part_pkg_option {
1387         'pkgpart'     => $pkgpart,
1388         'optionname'  => $_,
1389         'optionvalue' => 1,
1390       };
1391       $error = $new_opt->insert;
1392       die $error if $error;
1393     }
1394   }
1395 }
1396
1397 =item curuser_pkgs_sql
1398
1399 Returns an SQL fragment for searching for packages the current user can
1400 use, either via part_pkg.agentnum directly, or via agent type (see
1401 L<FS::type_pkgs>).
1402
1403 =cut
1404
1405 sub curuser_pkgs_sql {
1406   my $class = shift;
1407
1408   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1409
1410 }
1411
1412 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1413
1414 Returns an SQL fragment for searching for packages the provided agent or agents
1415 can use, either via part_pkg.agentnum directly, or via agent type (see
1416 L<FS::type_pkgs>).
1417
1418 =cut
1419
1420 sub agent_pkgs_sql {
1421   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1422   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1423
1424   $class->_pkgs_sql(@agentnums); #is this why
1425
1426 }
1427
1428 sub _pkgs_sql {
1429   my( $class, @agentnums ) = @_;
1430   my $agentnums = join(',', @agentnums);
1431
1432   "
1433     (
1434       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1435       OR ( agentnum IS NULL
1436            AND EXISTS ( SELECT 1
1437                           FROM type_pkgs
1438                             LEFT JOIN agent_type USING ( typenum )
1439                             LEFT JOIN agent AS typeagent USING ( typenum )
1440                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1441                             AND typeagent.agentnum IN ($agentnums)
1442                       )
1443          )
1444     )
1445   ";
1446
1447 }
1448
1449 =back
1450
1451 =head1 SUBROUTINES
1452
1453 =over 4
1454
1455 =item plan_info
1456
1457 =cut
1458
1459 #false laziness w/part_export & cdr
1460 my %info;
1461 foreach my $INC ( @INC ) {
1462   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1463   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1464     warn "attempting to load plan info from $file\n" if $DEBUG;
1465     $file =~ /\/(\w+)\.pm$/ or do {
1466       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1467       next;
1468     };
1469     my $mod = $1;
1470     my $info = eval "use FS::part_pkg::$mod; ".
1471                     "\\%FS::part_pkg::$mod\::info;";
1472     if ( $@ ) {
1473       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1474       next;
1475     }
1476     unless ( keys %$info ) {
1477       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1478       next;
1479     }
1480     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1481     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1482     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1483     #  next;
1484     #}
1485     $info{$mod} = $info;
1486     $info->{'weight'} ||= 0; # quiet warnings
1487   }
1488 }
1489
1490 # copy one level deep to allow replacement of fields and fieldorder
1491 tie %plans, 'Tie::IxHash',
1492   map  { my %infohash = %{ $info{$_} }; 
1493           $_ => \%infohash }
1494   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1495   keys %info;
1496
1497 # inheritance of plan options
1498 foreach my $name (keys(%info)) {
1499   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1500     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1501     delete $plans{$name};
1502     next;
1503   }
1504   my $parents = $info{$name}->{'inherit_fields'} || [];
1505   my (%fields, %field_exists, @fieldorder);
1506   foreach my $parent ($name, @$parents) {
1507     %fields = ( # avoid replacing existing fields
1508       %{ $info{$parent}->{'fields'} || {} },
1509       %fields
1510     );
1511     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
1512       # avoid duplicates
1513       next if $field_exists{$_};
1514       $field_exists{$_} = 1;
1515       # allow inheritors to remove inherited fields from the fieldorder
1516       push @fieldorder, $_ if !exists($fields{$_}->{'disabled'});
1517     }
1518   }
1519   $plans{$name}->{'fields'} = \%fields;
1520   $plans{$name}->{'fieldorder'} = \@fieldorder;
1521 }
1522
1523 sub plan_info {
1524   \%plans;
1525 }
1526
1527
1528 =back
1529
1530 =head1 NEW PLAN CLASSES
1531
1532 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1533 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1534 other modules in FS/FS/part_pkg/ as a guide.
1535
1536 =head1 BUGS
1537
1538 The delete method is unimplemented.
1539
1540 setup and recur semantics are not yet defined (and are implemented in
1541 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1542
1543 plandata should go
1544
1545 part_pkg_taxrate is Pg specific
1546
1547 replace should be smarter about managing the related tables (options, pkg_svc)
1548
1549 =head1 SEE ALSO
1550
1551 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1552 schema.html from the base documentation.
1553
1554 =cut
1555
1556 1;
1557