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