show package pricing details on customer view, RT11372
[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_moneyn('setup_cost')
488     #|| $self->ut_moneyn('recur_cost')
489     || $self->ut_floatn('setup_cost')
490     || $self->ut_floatn('recur_cost')
491     || $self->ut_floatn('pay_weight')
492     || $self->ut_floatn('credit_weight')
493     || $self->ut_numbern('taxproductnum')
494     || $self->ut_foreign_keyn('classnum',       'pkg_class', 'classnum')
495     || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum')
496     || $self->ut_foreign_keyn('taxproductnum',
497                               'part_pkg_taxproduct',
498                               'taxproductnum'
499                              )
500     || ( $setup_hack
501            ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' )
502            : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right)
503        )
504     || $self->ut_numbern('fcc_ds0s')
505     || $self->SUPER::check
506   ;
507   return $error if $error;
508
509   return 'Unknown plan '. $self->plan
510     unless exists($plans{$self->plan});
511
512   my $conf = new FS::Conf;
513   return 'Taxclass is required'
514     if ! $self->taxclass && $conf->exists('require_taxclasses');
515
516   '';
517 }
518
519 =item pkg_comment [ OPTION => VALUE... ]
520
521 Returns an (internal) string representing this package.  Currently,
522 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
523 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
524 custom is Y.
525
526 If the option nopkgpart is true then the "pkgpart: ' is omitted.
527
528 =cut
529
530 sub pkg_comment {
531   my $self = shift;
532   my %opt = @_;
533
534   #$self->pkg. ' - '. $self->comment;
535   #$self->pkg. ' ('. $self->comment. ')';
536   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
537   $pre. $self->pkg. ' - '. $self->custom_comment;
538 }
539
540 sub price_info { # safety, in case a part_pkg hasn't defined price_info
541     '';
542 }
543
544 sub custom_comment {
545   my $self = shift;
546   ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info;
547 }
548
549 =item pkg_class
550
551 Returns the package class, as an FS::pkg_class object, or the empty string
552 if there is no package class.
553
554 =cut
555
556 sub pkg_class {
557   my $self = shift;
558   if ( $self->classnum ) {
559     qsearchs('pkg_class', { 'classnum' => $self->classnum } );
560   } else {
561     return '';
562   }
563 }
564
565 =item addon_pkg_class
566
567 Returns the add-on package class, as an FS::pkg_class object, or the empty
568 string if there is no add-on package class.
569
570 =cut
571
572 sub addon_pkg_class {
573   my $self = shift;
574   if ( $self->addon_classnum ) {
575     qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } );
576   } else {
577     return '';
578   }
579 }
580
581 =item categoryname 
582
583 Returns the package category name, or the empty string if there is no package
584 category.
585
586 =cut
587
588 sub categoryname {
589   my $self = shift;
590   my $pkg_class = $self->pkg_class;
591   $pkg_class
592     ? $pkg_class->categoryname
593     : '';
594 }
595
596 =item classname 
597
598 Returns the package class name, or the empty string if there is no package
599 class.
600
601 =cut
602
603 sub classname {
604   my $self = shift;
605   my $pkg_class = $self->pkg_class;
606   $pkg_class
607     ? $pkg_class->classname
608     : '';
609 }
610
611 =item addon_classname 
612
613 Returns the add-on package class name, or the empty string if there is no
614 add-on package class.
615
616 =cut
617
618 sub addon_classname {
619   my $self = shift;
620   my $pkg_class = $self->addon_pkg_class;
621   $pkg_class
622     ? $pkg_class->classname
623     : '';
624 }
625
626 =item agent 
627
628 Returns the associated agent for this event, if any, as an FS::agent object.
629
630 =cut
631
632 sub agent {
633   my $self = shift;
634   qsearchs('agent', { 'agentnum' => $self->agentnum } );
635 }
636
637 =item pkg_svc [ HASHREF | OPTION => VALUE ]
638
639 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
640 definition (with non-zero quantity).
641
642 One option is available, I<disable_linked>.  If set true it will return the
643 services for this package definition alone, omitting services from any add-on
644 packages.
645
646 =cut
647
648 =item type_pkgs
649
650 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
651 definition.
652
653 =cut
654
655 sub type_pkgs {
656   my $self = shift;
657   qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
658 }
659
660 sub pkg_svc {
661   my $self = shift;
662
663 #  #sort { $b->primary cmp $a->primary } 
664 #    grep { $_->quantity }
665 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
666
667   my $opt = ref($_[0]) ? $_[0] : { @_ };
668   my %pkg_svc = map  { $_->svcpart => $_ }
669                 grep { $_->quantity }
670                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
671
672   unless ( $opt->{disable_linked} ) {
673     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
674       my @pkg_svc = grep { $_->quantity }
675                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
676       foreach my $pkg_svc ( @pkg_svc ) {
677         if ( $pkg_svc{$pkg_svc->svcpart} ) {
678           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
679           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
680         } else {
681           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
682         }
683       }
684     }
685   }
686
687   values(%pkg_svc);
688
689 }
690
691 =item svcpart [ SVCDB ]
692
693 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
694 associated with this package definition (see L<FS::pkg_svc>).  Returns
695 false if there not a primary service definition or exactly one service
696 definition with quantity 1, or if SVCDB is specified and does not match the
697 svcdb of the service definition.  SVCDB can be specified as a scalar table
698 name, such as 'svc_acct', or as an arrayref of possible table names.
699
700 =cut
701
702 sub svcpart {
703   my $pkg_svc = shift->_primary_pkg_svc(@_);
704   $pkg_svc ? $pkg_svc->svcpart : '';
705 }
706
707 =item part_svc [ SVCDB ]
708
709 Like the B<svcpart> method, but returns the FS::part_svc object (see
710 L<FS::part_svc>).
711
712 =cut
713
714 sub part_svc {
715   my $pkg_svc = shift->_primary_pkg_svc(@_);
716   $pkg_svc ? $pkg_svc->part_svc : '';
717 }
718
719 sub _primary_pkg_svc {
720   my $self = shift;
721
722   my $svcdb = scalar(@_) ? shift : [];
723   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
724   my %svcdb = map { $_=>1 } @$svcdb;
725
726   my @svcdb_pkg_svc =
727     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
728          $self->pkg_svc;
729
730   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
731   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
732     unless @pkg_svc;
733   return '' if scalar(@pkg_svc) != 1;
734   $pkg_svc[0];
735 }
736
737 =item svcpart_unique_svcdb SVCDB
738
739 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
740 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
741 false if there not a primary service definition for SVCDB or there are multiple
742 service definitions for SVCDB.
743
744 =cut
745
746 sub svcpart_unique_svcdb {
747   my( $self, $svcdb ) = @_;
748   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
749   return '' if scalar(@svcdb_pkg_svc) != 1;
750   $svcdb_pkg_svc[0]->svcpart;
751 }
752
753 =item payby
754
755 Returns a list of the acceptable payment types for this package.  Eventually
756 this should come out of a database table and be editable, but currently has the
757 following logic instead:
758
759 If the package is free, the single item B<BILL> is
760 returned, otherwise, the single item B<CARD> is returned.
761
762 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
763
764 =cut
765
766 sub payby {
767   my $self = shift;
768   if ( $self->is_free ) {
769     ( 'BILL' );
770   } else {
771     ( 'CARD' );
772   }
773 }
774
775 =item is_free
776
777 Returns true if this package is free.  
778
779 =cut
780
781 sub is_free {
782   my $self = shift;
783   unless ( $self->plan ) {
784     $self->setup =~ /^\s*0+(\.0*)?\s*$/
785       && $self->recur =~ /^\s*0+(\.0*)?\s*$/;
786   } elsif ( $self->can('is_free_options') ) {
787     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
788          map { $self->option($_) } 
789              $self->is_free_options;
790   } else {
791     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
792          "provides neither is_free_options nor is_free method; returning false";
793     0;
794   }
795 }
796
797 sub can_discount { 0; }
798
799 sub freqs_href {
800   # moved to FS::Misc to make this accessible to other packages
801   # at initialization
802   FS::Misc::pkg_freqs();
803 }
804
805 =item freq_pretty
806
807 Returns an english representation of the I<freq> field, such as "monthly",
808 "weekly", "semi-annually", etc.
809
810 =cut
811
812 sub freq_pretty {
813   my $self = shift;
814   my $freq = $self->freq;
815
816   #my $freqs_href = $self->freqs_href;
817   my $freqs_href = freqs_href();
818
819   if ( exists($freqs_href->{$freq}) ) {
820     $freqs_href->{$freq};
821   } else {
822     my $interval = 'month';
823     if ( $freq =~ /^(\d+)([hdw])$/ ) {
824       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
825       $interval = $interval{$2};
826     }
827     if ( $1 == 1 ) {
828       "every $interval";
829     } else {
830       "every $freq ${interval}s";
831     }
832   }
833 }
834
835 =item add_freq TIMESTAMP [ FREQ ]
836
837 Adds a billing period of some frequency to the provided timestamp and 
838 returns the resulting timestamp, or -1 if the frequency could not be 
839 parsed (shouldn't happen).  By default, the frequency of this package 
840 will be used; to override this, pass a different frequency as a second 
841 argument.
842
843 =cut
844
845 sub add_freq {
846   my( $self, $date, $freq ) = @_;
847   $freq = $self->freq unless $freq;
848
849   #change this bit to use Date::Manip? CAREFUL with timezones (see
850   # mailing list archive)
851   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
852
853   if ( $freq =~ /^\d+$/ ) {
854     $mon += $freq;
855     until ( $mon < 12 ) { $mon -= 12; $year++; }
856   } elsif ( $freq =~ /^(\d+)w$/ ) {
857     my $weeks = $1;
858     $mday += $weeks * 7;
859   } elsif ( $freq =~ /^(\d+)d$/ ) {
860     my $days = $1;
861     $mday += $days;
862   } elsif ( $freq =~ /^(\d+)h$/ ) {
863     my $hours = $1;
864     $hour += $hours;
865   } else {
866     return -1;
867   }
868
869   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
870 }
871
872 =item plandata
873
874 For backwards compatibility, returns the plandata field as well as all options
875 from FS::part_pkg_option.
876
877 =cut
878
879 sub plandata {
880   my $self = shift;
881   carp "plandata is deprecated";
882   if ( @_ ) {
883     $self->SUPER::plandata(@_);
884   } else {
885     my $plandata = $self->get('plandata');
886     my %options = $self->options;
887     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
888     $plandata;
889   }
890 }
891
892 =item part_pkg_option
893
894 Returns all options as FS::part_pkg_option objects (see
895 L<FS::part_pkg_option>).
896
897 =cut
898
899 sub part_pkg_option {
900   my $self = shift;
901   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
902 }
903
904 =item options 
905
906 Returns a list of option names and values suitable for assigning to a hash.
907
908 =cut
909
910 sub options {
911   my $self = shift;
912   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
913 }
914
915 =item option OPTIONNAME [ QUIET ]
916
917 Returns the option value for the given name, or the empty string.  If a true
918 value is passed as the second argument, warnings about missing the option
919 will be suppressed.
920
921 =cut
922
923 sub option {
924   my( $self, $opt, $ornull ) = @_;
925   my $part_pkg_option =
926     qsearchs('part_pkg_option', {
927       pkgpart    => $self->pkgpart,
928       optionname => $opt,
929   } );
930   return $part_pkg_option->optionvalue if $part_pkg_option;
931   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
932                      split("\n", $self->get('plandata') );
933   return $plandata{$opt} if exists $plandata{$opt};
934   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
935         "not found in options or plandata!\n"
936     unless $ornull;
937   '';
938 }
939
940 =item bill_part_pkg_link
941
942 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
943
944 =cut
945
946 sub bill_part_pkg_link {
947   shift->_part_pkg_link('bill', @_);
948 }
949
950 =item svc_part_pkg_link
951
952 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
953
954 =cut
955
956 sub svc_part_pkg_link {
957   shift->_part_pkg_link('svc', @_);
958 }
959
960 sub _part_pkg_link {
961   my( $self, $type ) = @_;
962   qsearch({ table    => 'part_pkg_link',
963             hashref  => { 'src_pkgpart' => $self->pkgpart,
964                           'link_type'   => $type,
965                           #protection against infinite recursive links
966                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
967                         },
968             order_by => "ORDER BY hidden",
969          });
970 }
971
972 sub self_and_bill_linked {
973   shift->_self_and_linked('bill', @_);
974 }
975
976 sub _self_and_linked {
977   my( $self, $type, $hidden ) = @_;
978   $hidden ||= '';
979
980   my @result = ();
981   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
982                      $self->_part_pkg_link($type) ) )
983   {
984     $_->hidden($hidden) if $hidden;
985     push @result, $_;
986   }
987
988   (@result);
989 }
990
991 =item part_pkg_taxoverride [ CLASS ]
992
993 Returns all associated FS::part_pkg_taxoverride objects (see
994 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
995 of class CLASS if defined.  Class may be one of 'setup', 'recur',
996 the empty string (default), or a usage class number (see L<FS::usage_class>).
997 When a class is specified, the empty string class (default) is returned
998 if no more specific values exist.
999
1000 =cut
1001
1002 sub part_pkg_taxoverride {
1003   my $self = shift;
1004   my $class = shift;
1005
1006   my $hashref = { 'pkgpart' => $self->pkgpart };
1007   $hashref->{'usage_class'} = $class if defined($class);
1008   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1009
1010   unless ( scalar(@overrides) || !defined($class) || !$class ){
1011     $hashref->{'usage_class'} = '';
1012     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1013   }
1014
1015   @overrides;
1016 }
1017
1018 =item has_taxproduct
1019
1020 Returns true if this package has any taxproduct associated with it.  
1021
1022 =cut
1023
1024 sub has_taxproduct {
1025   my $self = shift;
1026
1027   $self->taxproductnum ||
1028   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1029           keys %{ {$self->options} }
1030   )
1031
1032 }
1033
1034
1035 =item taxproduct [ CLASS ]
1036
1037 Returns the associated tax product for this package definition (see
1038 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1039 the usage classnum (see L<FS::usage_class>).  Returns the default
1040 tax product for this record if the more specific CLASS value does
1041 not exist.
1042
1043 =cut
1044
1045 sub taxproduct {
1046   my $self = shift;
1047   my $class = shift;
1048
1049   my $part_pkg_taxproduct;
1050
1051   my $taxproductnum = $self->taxproductnum;
1052   if ($class) { 
1053     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1054     $taxproductnum = $class_taxproductnum
1055       if $class_taxproductnum
1056   }
1057   
1058   $part_pkg_taxproduct =
1059     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1060
1061   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1062     $taxproductnum = $self->taxproductnum;
1063     $part_pkg_taxproduct =
1064       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1065   }
1066
1067   $part_pkg_taxproduct;
1068 }
1069
1070 =item taxproduct_description [ CLASS ]
1071
1072 Returns the description of the associated tax product for this package
1073 definition (see L<FS::part_pkg_taxproduct>).
1074
1075 =cut
1076
1077 sub taxproduct_description {
1078   my $self = shift;
1079   my $part_pkg_taxproduct = $self->taxproduct(@_);
1080   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1081 }
1082
1083 =item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
1084
1085 Returns the package to taxrate m2m records for this package in the location
1086 specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
1087 CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
1088 (see L<FS::usage_class>).
1089
1090 =cut
1091
1092 sub _expand_cch_taxproductnum {
1093   my $self = shift;
1094   my $class = shift;
1095   my $part_pkg_taxproduct = $self->taxproduct($class);
1096
1097   my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
1098                          ? ( split ':', $part_pkg_taxproduct->taxproduct )
1099                          : ()
1100                      );
1101   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
1102   my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
1103                       OR taxproduct = '$a:$b:$c:'
1104                       OR taxproduct = '$a:$b:".":$d'
1105                       OR taxproduct = '$a:$b:".":' )";
1106   map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
1107                                        'hashref'   => { 'data_vendor'=>'cch' },
1108                                        'extra_sql' => $extra_sql,
1109                                    } );
1110                                      
1111 }
1112
1113 sub part_pkg_taxrate {
1114   my $self = shift;
1115   my ($data_vendor, $geocode, $class) = @_;
1116
1117   my $dbh = dbh;
1118   my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
1119                   dbh->quote($data_vendor);
1120   
1121   # CCH oddness in m2m
1122   $extra_sql .= ' AND ('.
1123     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
1124                  qw(10 5 2)
1125         ).
1126     ')';
1127   # much more CCH oddness in m2m -- this is kludgy
1128   my @tpnums = $self->_expand_cch_taxproductnum($class);
1129   if (scalar(@tpnums)) {
1130     $extra_sql .= ' AND ('.
1131                             join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
1132                        ')';
1133   } else {
1134     $extra_sql .= ' AND ( 0 = 1 )';
1135   }
1136
1137   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
1138   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
1139   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
1140
1141   # should qsearch preface columns with the table to facilitate joins?
1142   qsearch( { 'table'     => 'part_pkg_taxrate',
1143              'select'    => $select,
1144              'hashref'   => { # 'data_vendor'   => $data_vendor,
1145                               # 'taxproductnum' => $self->taxproductnum,
1146                             },
1147              'addl_from' => $addl_from,
1148              'extra_sql' => $extra_sql,
1149              'order_by'  => $order_by,
1150          } );
1151 }
1152
1153 =item part_pkg_discount
1154
1155 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1156 for this package.
1157
1158 =cut
1159
1160 sub part_pkg_discount {
1161   my $self = shift;
1162   qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
1163 }
1164
1165 =item _rebless
1166
1167 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1168 PLAN is the object's I<plan> field.  There should be better docs
1169 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1170
1171 =cut
1172
1173 sub _rebless {
1174   my $self = shift;
1175   my $plan = $self->plan;
1176   unless ( $plan ) {
1177     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1178       if $DEBUG;
1179     return $self;
1180   }
1181   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1182   my $class = ref($self). "::$plan";
1183   warn "reblessing $self into $class" if $DEBUG;
1184   eval "use $class;";
1185   die $@ if $@;
1186   bless($self, $class) unless $@;
1187   $self;
1188 }
1189
1190 #fallbacks that eval the setup and recur fields, for backwards compat
1191
1192 sub calc_setup {
1193   my $self = shift;
1194   warn 'no price plan class for '. $self->plan. ", eval-ing setup\n";
1195   $self->_calc_eval('setup', @_);
1196 }
1197
1198 sub calc_recur {
1199   my $self = shift;
1200   warn 'no price plan class for '. $self->plan. ", eval-ing recur\n";
1201   $self->_calc_eval('recur', @_);
1202 }
1203
1204 use vars qw( $sdate @details );
1205 sub _calc_eval {
1206   #my( $self, $field, $cust_pkg ) = @_;
1207   my( $self, $field, $cust_pkg, $sdateref, $detailsref ) = @_;
1208   *sdate = $sdateref;
1209   *details = $detailsref;
1210   $self->$field() =~ /^(.*)$/
1211     or die "Illegal $field (pkgpart ". $self->pkgpart. '): '.
1212             $self->$field(). "\n";
1213   my $prog = $1;
1214   return 0 if $prog =~ /^\s*$/;
1215   my $value = eval $prog;
1216   die $@ if $@;
1217   $value;
1218 }
1219
1220 #fallback that return 0 for old legacy packages with no plan
1221
1222 sub calc_remain { 0; }
1223 sub calc_cancel { 0; }
1224 sub calc_units  { 0; }
1225
1226 #fallback for everything except bulk.pm
1227 sub hide_svc_detail { 0; }
1228
1229 =item recur_cost_permonth CUST_PKG
1230
1231 recur_cost divided by freq (only supported for monthly and longer frequencies)
1232
1233 =cut
1234
1235 sub recur_cost_permonth {
1236   my($self, $cust_pkg) = @_;
1237   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1238   sprintf('%.2f', $self->recur_cost / $self->freq );
1239 }
1240
1241 =item format OPTION DATA
1242
1243 Returns data formatted according to the function 'format' described
1244 in the plan info.  Returns DATA if no such function exists.
1245
1246 =cut
1247
1248 sub format {
1249   my ($self, $option, $data) = (shift, shift, shift);
1250   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1251     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1252   }else{
1253     $data;
1254   }
1255 }
1256
1257 =item parse OPTION DATA
1258
1259 Returns data parsed according to the function 'parse' described
1260 in the plan info.  Returns DATA if no such function exists.
1261
1262 =cut
1263
1264 sub parse {
1265   my ($self, $option, $data) = (shift, shift, shift);
1266   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1267     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1268   }else{
1269     $data;
1270   }
1271 }
1272
1273 =back
1274
1275 =cut
1276
1277 =head1 CLASS METHODS
1278
1279 =over 4
1280
1281 =cut
1282
1283 # _upgrade_data
1284 #
1285 # Used by FS::Upgrade to migrate to a new database.
1286
1287 sub _upgrade_data { # class method
1288   my($class, %opts) = @_;
1289
1290   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1291
1292   my @part_pkg = qsearch({
1293     'table'     => 'part_pkg',
1294     'extra_sql' => "WHERE ". join(' OR ',
1295                      ( map "($_ IS NOT NULL AND $_ != '' )",
1296                            qw( plandata setup recur ) ),
1297                      'plan IS NULL', "plan = '' ",
1298                    ),
1299   });
1300
1301   foreach my $part_pkg (@part_pkg) {
1302
1303     unless ( $part_pkg->plan ) {
1304       $part_pkg->plan('flat');
1305     }
1306
1307     if ( length($part_pkg->option('setup_fee')) == 0 
1308          && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) {
1309
1310       my $opt = new FS::part_pkg_option {
1311         'pkgpart'     => $part_pkg->pkgpart,
1312         'optionname'  => 'setup_fee',
1313         'optionvalue' => $1,
1314       };
1315       my $error = $opt->insert;
1316       die $error if $error;
1317
1318
1319       #} else {
1320       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1321       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1322     }
1323     $part_pkg->setup('');
1324
1325     if ( length($part_pkg->option('recur_fee')) == 0
1326          && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) {
1327
1328         my $opt = new FS::part_pkg_option {
1329           'pkgpart'     => $part_pkg->pkgpart,
1330           'optionname'  => 'recur_fee',
1331           'optionvalue' => $1,
1332         };
1333         my $error = $opt->insert;
1334         die $error if $error;
1335
1336
1337       #} else {
1338       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1339       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1340     }
1341     $part_pkg->recur('');
1342
1343     $part_pkg->replace; #this should take care of plandata, right?
1344
1345   }
1346
1347   # now upgrade to the explicit custom flag
1348
1349   @part_pkg = qsearch({
1350     'table'     => 'part_pkg',
1351     'hashref'   => { disabled => 'Y', custom => '' },
1352     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1353   });
1354
1355   foreach my $part_pkg (@part_pkg) {
1356     my $new = new FS::part_pkg { $part_pkg->hash };
1357     $new->custom('Y');
1358     my $comment = $part_pkg->comment;
1359     $comment =~ s/^\(CUSTOM\) //;
1360     $comment = '(none)' unless $comment =~ /\S/;
1361     $new->comment($comment);
1362
1363     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1364     my $primary = $part_pkg->svcpart;
1365     my $options = { $part_pkg->options };
1366
1367     my $error = $new->replace( $part_pkg,
1368                                'pkg_svc'     => $pkg_svc,
1369                                'primary_svc' => $primary,
1370                                'options'     => $options,
1371                              );
1372     die $error if $error;
1373   }
1374
1375   my @part_pkg_option = qsearch('part_pkg_option',
1376     { 'optionname'  => 'unused_credit',
1377       'optionvalue' => 1,
1378     });
1379   foreach my $old_opt (@part_pkg_option) {
1380     my $pkgpart = $old_opt->pkgpart;
1381     my $error = $old_opt->delete;
1382     die $error if $error;
1383
1384     foreach (qw(unused_credit_cancel unused_credit_change)) {
1385       my $new_opt = new FS::part_pkg_option {
1386         'pkgpart'     => $pkgpart,
1387         'optionname'  => $_,
1388         'optionvalue' => 1,
1389       };
1390       $error = $new_opt->insert;
1391       die $error if $error;
1392     }
1393   }
1394 }
1395
1396 =item curuser_pkgs_sql
1397
1398 Returns an SQL fragment for searching for packages the current user can
1399 use, either via part_pkg.agentnum directly, or via agent type (see
1400 L<FS::type_pkgs>).
1401
1402 =cut
1403
1404 sub curuser_pkgs_sql {
1405   my $class = shift;
1406
1407   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1408
1409 }
1410
1411 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1412
1413 Returns an SQL fragment for searching for packages the provided agent or agents
1414 can use, either via part_pkg.agentnum directly, or via agent type (see
1415 L<FS::type_pkgs>).
1416
1417 =cut
1418
1419 sub agent_pkgs_sql {
1420   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1421   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1422
1423   $class->_pkgs_sql(@agentnums); #is this why
1424
1425 }
1426
1427 sub _pkgs_sql {
1428   my( $class, @agentnums ) = @_;
1429   my $agentnums = join(',', @agentnums);
1430
1431   "
1432     (
1433       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1434       OR ( agentnum IS NULL
1435            AND EXISTS ( SELECT 1
1436                           FROM type_pkgs
1437                             LEFT JOIN agent_type USING ( typenum )
1438                             LEFT JOIN agent AS typeagent USING ( typenum )
1439                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1440                             AND typeagent.agentnum IN ($agentnums)
1441                       )
1442          )
1443     )
1444   ";
1445
1446 }
1447
1448 =back
1449
1450 =head1 SUBROUTINES
1451
1452 =over 4
1453
1454 =item plan_info
1455
1456 =cut
1457
1458 #false laziness w/part_export & cdr
1459 my %info;
1460 foreach my $INC ( @INC ) {
1461   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1462   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1463     warn "attempting to load plan info from $file\n" if $DEBUG;
1464     $file =~ /\/(\w+)\.pm$/ or do {
1465       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1466       next;
1467     };
1468     my $mod = $1;
1469     my $info = eval "use FS::part_pkg::$mod; ".
1470                     "\\%FS::part_pkg::$mod\::info;";
1471     if ( $@ ) {
1472       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1473       next;
1474     }
1475     unless ( keys %$info ) {
1476       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1477       next;
1478     }
1479     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1480     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1481     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1482     #  next;
1483     #}
1484     $info{$mod} = $info;
1485     $info->{'weight'} ||= 0; # quiet warnings
1486   }
1487 }
1488
1489 # copy one level deep to allow replacement of fields and fieldorder
1490 tie %plans, 'Tie::IxHash',
1491   map  { my %infohash = %{ $info{$_} }; 
1492           $_ => \%infohash }
1493   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1494   keys %info;
1495
1496 # inheritance of plan options
1497 foreach my $name (keys(%info)) {
1498   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1499     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1500     delete $plans{$name};
1501     next;
1502   }
1503   my $parents = $info{$name}->{'inherit_fields'} || [];
1504   my (%fields, %field_exists, @fieldorder);
1505   foreach my $parent ($name, @$parents) {
1506     %fields = ( # avoid replacing existing fields
1507       %{ $info{$parent}->{'fields'} || {} },
1508       %fields
1509     );
1510     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
1511       # avoid duplicates
1512       next if $field_exists{$_};
1513       $field_exists{$_} = 1;
1514       # allow inheritors to remove inherited fields from the fieldorder
1515       push @fieldorder, $_ if !exists($fields{$_}->{'disabled'});
1516     }
1517   }
1518   $plans{$name}->{'fields'} = \%fields;
1519   $plans{$name}->{'fieldorder'} = \@fieldorder;
1520 }
1521
1522 sub plan_info {
1523   \%plans;
1524 }
1525
1526
1527 =back
1528
1529 =head1 NEW PLAN CLASSES
1530
1531 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1532 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1533 other modules in FS/FS/part_pkg/ as a guide.
1534
1535 =head1 BUGS
1536
1537 The delete method is unimplemented.
1538
1539 setup and recur semantics are not yet defined (and are implemented in
1540 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1541
1542 plandata should go
1543
1544 part_pkg_taxrate is Pg specific
1545
1546 replace should be smarter about managing the related tables (options, pkg_svc)
1547
1548 =head1 SEE ALSO
1549
1550 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1551 schema.html from the base documentation.
1552
1553 =cut
1554
1555 1;
1556