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