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