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