8db0e9902d35859708612845fafe5076ffefdacf
[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 default start date; takes an FS::cust_main as an argument
879 sub default_start_date {
880   my $self = shift;
881   my $cust_main = shift;
882   my $conf = FS::Conf->new;
883
884   if ( $self->delay_start ) {
885     my $delay = $self->delay_start;
886     
887     my ($mday,$mon,$year) = (localtime(time))[3,4,5];
888     my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $delay;
889     return $start_date;
890
891   } elsif ( $conf->exists('order_pkg-no_start_date') ) {
892
893     return ''
894
895   } else {
896     
897     return $cust_main->next_bill_date;
898   
899   }
900 }
901
902 sub freqs_href {
903   # moved to FS::Misc to make this accessible to other packages
904   # at initialization
905   FS::Misc::pkg_freqs();
906 }
907
908 =item freq_pretty
909
910 Returns an english representation of the I<freq> field, such as "monthly",
911 "weekly", "semi-annually", etc.
912
913 =cut
914
915 sub freq_pretty {
916   my $self = shift;
917   my $freq = $self->freq;
918
919   #my $freqs_href = $self->freqs_href;
920   my $freqs_href = freqs_href();
921
922   if ( exists($freqs_href->{$freq}) ) {
923     $freqs_href->{$freq};
924   } else {
925     my $interval = 'month';
926     if ( $freq =~ /^(\d+)([hdw])$/ ) {
927       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
928       $interval = $interval{$2};
929     }
930     if ( $1 == 1 ) {
931       "every $interval";
932     } else {
933       "every $freq ${interval}s";
934     }
935   }
936 }
937
938 =item add_freq TIMESTAMP [ FREQ ]
939
940 Adds a billing period of some frequency to the provided timestamp and 
941 returns the resulting timestamp, or -1 if the frequency could not be 
942 parsed (shouldn't happen).  By default, the frequency of this package 
943 will be used; to override this, pass a different frequency as a second 
944 argument.
945
946 =cut
947
948 sub add_freq {
949   my( $self, $date, $freq ) = @_;
950   $freq = $self->freq unless $freq;
951
952   #change this bit to use Date::Manip? CAREFUL with timezones (see
953   # mailing list archive)
954   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
955
956   if ( $freq =~ /^\d+$/ ) {
957     $mon += $freq;
958     until ( $mon < 12 ) { $mon -= 12; $year++; }
959
960     $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback');
961
962   } elsif ( $freq =~ /^(\d+)w$/ ) {
963     my $weeks = $1;
964     $mday += $weeks * 7;
965   } elsif ( $freq =~ /^(\d+)d$/ ) {
966     my $days = $1;
967     $mday += $days;
968   } elsif ( $freq =~ /^(\d+)h$/ ) {
969     my $hours = $1;
970     $hour += $hours;
971   } else {
972     return -1;
973   }
974
975   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
976 }
977
978 =item plandata
979
980 For backwards compatibility, returns the plandata field as well as all options
981 from FS::part_pkg_option.
982
983 =cut
984
985 sub plandata {
986   my $self = shift;
987   carp "plandata is deprecated";
988   if ( @_ ) {
989     $self->SUPER::plandata(@_);
990   } else {
991     my $plandata = $self->get('plandata');
992     my %options = $self->options;
993     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
994     $plandata;
995   }
996 }
997
998 =item part_pkg_vendor
999
1000 Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
1001 L<FS::part_pkg_vendor>).
1002
1003 =cut
1004
1005 sub part_pkg_vendor {
1006   my $self = shift;
1007   qsearch('part_pkg_vendor', { 'pkgpart' => $self->pkgpart } );
1008 }
1009
1010 =item vendor_pkg_ids
1011
1012 Returns a list of vendor/external package ids by exportnum
1013
1014 =cut
1015
1016 sub vendor_pkg_ids {
1017   my $self = shift;
1018   map { $_->exportnum => $_->vendor_pkg_id } $self->part_pkg_vendor;
1019 }
1020
1021 =item part_pkg_option
1022
1023 Returns all options as FS::part_pkg_option objects (see
1024 L<FS::part_pkg_option>).
1025
1026 =cut
1027
1028 sub part_pkg_option {
1029   my $self = shift;
1030   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
1031 }
1032
1033 =item options 
1034
1035 Returns a list of option names and values suitable for assigning to a hash.
1036
1037 =cut
1038
1039 sub options {
1040   my $self = shift;
1041   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
1042 }
1043
1044 =item option OPTIONNAME [ QUIET ]
1045
1046 Returns the option value for the given name, or the empty string.  If a true
1047 value is passed as the second argument, warnings about missing the option
1048 will be suppressed.
1049
1050 =cut
1051
1052 sub option {
1053   my( $self, $opt, $ornull ) = @_;
1054   my $part_pkg_option =
1055     qsearchs('part_pkg_option', {
1056       pkgpart    => $self->pkgpart,
1057       optionname => $opt,
1058   } );
1059   return $part_pkg_option->optionvalue if $part_pkg_option;
1060   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
1061                      split("\n", $self->get('plandata') );
1062   return $plandata{$opt} if exists $plandata{$opt};
1063   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
1064         "not found in options or plandata!\n"
1065     unless $ornull;
1066   '';
1067 }
1068
1069 =item bill_part_pkg_link
1070
1071 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1072
1073 =cut
1074
1075 sub bill_part_pkg_link {
1076   shift->_part_pkg_link('bill', @_);
1077 }
1078
1079 =item svc_part_pkg_link
1080
1081 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1082
1083 =cut
1084
1085 sub svc_part_pkg_link {
1086   shift->_part_pkg_link('svc', @_);
1087 }
1088
1089 sub _part_pkg_link {
1090   my( $self, $type ) = @_;
1091   qsearch({ table    => 'part_pkg_link',
1092             hashref  => { 'src_pkgpart' => $self->pkgpart,
1093                           'link_type'   => $type,
1094                           #protection against infinite recursive links
1095                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
1096                         },
1097             order_by => "ORDER BY hidden",
1098          });
1099 }
1100
1101 sub self_and_bill_linked {
1102   shift->_self_and_linked('bill', @_);
1103 }
1104
1105 sub self_and_svc_linked {
1106   shift->_self_and_linked('svc', @_);
1107 }
1108
1109 sub _self_and_linked {
1110   my( $self, $type, $hidden ) = @_;
1111   $hidden ||= '';
1112
1113   my @result = ();
1114   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
1115                      $self->_part_pkg_link($type) ) )
1116   {
1117     $_->hidden($hidden) if $hidden;
1118     push @result, $_;
1119   }
1120
1121   (@result);
1122 }
1123
1124 =item part_pkg_taxoverride [ CLASS ]
1125
1126 Returns all associated FS::part_pkg_taxoverride objects (see
1127 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
1128 of class CLASS if defined.  Class may be one of 'setup', 'recur',
1129 the empty string (default), or a usage class number (see L<FS::usage_class>).
1130 When a class is specified, the empty string class (default) is returned
1131 if no more specific values exist.
1132
1133 =cut
1134
1135 sub part_pkg_taxoverride {
1136   my $self = shift;
1137   my $class = shift;
1138
1139   my $hashref = { 'pkgpart' => $self->pkgpart };
1140   $hashref->{'usage_class'} = $class if defined($class);
1141   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1142
1143   unless ( scalar(@overrides) || !defined($class) || !$class ){
1144     $hashref->{'usage_class'} = '';
1145     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1146   }
1147
1148   @overrides;
1149 }
1150
1151 =item has_taxproduct
1152
1153 Returns true if this package has any taxproduct associated with it.  
1154
1155 =cut
1156
1157 sub has_taxproduct {
1158   my $self = shift;
1159
1160   $self->taxproductnum ||
1161   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1162           keys %{ {$self->options} }
1163   )
1164
1165 }
1166
1167
1168 =item taxproduct [ CLASS ]
1169
1170 Returns the associated tax product for this package definition (see
1171 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1172 the usage classnum (see L<FS::usage_class>).  Returns the default
1173 tax product for this record if the more specific CLASS value does
1174 not exist.
1175
1176 =cut
1177
1178 sub taxproduct {
1179   my $self = shift;
1180   my $class = shift;
1181
1182   my $part_pkg_taxproduct;
1183
1184   my $taxproductnum = $self->taxproductnum;
1185   if ($class) { 
1186     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1187     $taxproductnum = $class_taxproductnum
1188       if $class_taxproductnum
1189   }
1190   
1191   $part_pkg_taxproduct =
1192     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1193
1194   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1195     $taxproductnum = $self->taxproductnum;
1196     $part_pkg_taxproduct =
1197       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1198   }
1199
1200   $part_pkg_taxproduct;
1201 }
1202
1203 =item taxproduct_description [ CLASS ]
1204
1205 Returns the description of the associated tax product for this package
1206 definition (see L<FS::part_pkg_taxproduct>).
1207
1208 =cut
1209
1210 sub taxproduct_description {
1211   my $self = shift;
1212   my $part_pkg_taxproduct = $self->taxproduct(@_);
1213   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1214 }
1215
1216 =item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
1217
1218 Returns the package to taxrate m2m records for this package in the location
1219 specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
1220 CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
1221 (see L<FS::usage_class>).
1222
1223 =cut
1224
1225 sub _expand_cch_taxproductnum {
1226   my $self = shift;
1227   my $class = shift;
1228   my $part_pkg_taxproduct = $self->taxproduct($class);
1229
1230   my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
1231                          ? ( split ':', $part_pkg_taxproduct->taxproduct )
1232                          : ()
1233                      );
1234   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
1235   my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
1236                       OR taxproduct = '$a:$b:$c:'
1237                       OR taxproduct = '$a:$b:".":$d'
1238                       OR taxproduct = '$a:$b:".":' )";
1239   map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
1240                                        'hashref'   => { 'data_vendor'=>'cch' },
1241                                        'extra_sql' => $extra_sql,
1242                                    } );
1243                                      
1244 }
1245
1246 sub part_pkg_taxrate {
1247   my $self = shift;
1248   my ($data_vendor, $geocode, $class) = @_;
1249
1250   my $dbh = dbh;
1251   my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
1252                   dbh->quote($data_vendor);
1253   
1254   # CCH oddness in m2m
1255   $extra_sql .= ' AND ('.
1256     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
1257                  qw(10 5 2)
1258         ).
1259     ')';
1260   # much more CCH oddness in m2m -- this is kludgy
1261   my @tpnums = $self->_expand_cch_taxproductnum($class);
1262   if (scalar(@tpnums)) {
1263     $extra_sql .= ' AND ('.
1264                             join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
1265                        ')';
1266   } else {
1267     $extra_sql .= ' AND ( 0 = 1 )';
1268   }
1269
1270   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
1271   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
1272   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
1273
1274   # should qsearch preface columns with the table to facilitate joins?
1275   qsearch( { 'table'     => 'part_pkg_taxrate',
1276              'select'    => $select,
1277              'hashref'   => { # 'data_vendor'   => $data_vendor,
1278                               # 'taxproductnum' => $self->taxproductnum,
1279                             },
1280              'addl_from' => $addl_from,
1281              'extra_sql' => $extra_sql,
1282              'order_by'  => $order_by,
1283          } );
1284 }
1285
1286 =item part_pkg_discount
1287
1288 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1289 for this package.
1290
1291 =cut
1292
1293 sub part_pkg_discount {
1294   my $self = shift;
1295   qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
1296 }
1297
1298 =item _rebless
1299
1300 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1301 PLAN is the object's I<plan> field.  There should be better docs
1302 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1303
1304 =cut
1305
1306 sub _rebless {
1307   my $self = shift;
1308   my $plan = $self->plan;
1309   unless ( $plan ) {
1310     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1311       if $DEBUG;
1312     return $self;
1313   }
1314   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1315   my $class = ref($self). "::$plan";
1316   warn "reblessing $self into $class" if $DEBUG;
1317   eval "use $class;";
1318   die $@ if $@;
1319   bless($self, $class) unless $@;
1320   $self;
1321 }
1322
1323 #fatal fallbacks
1324 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
1325 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
1326
1327 #fallback that return 0 for old legacy packages with no plan
1328 sub calc_remain { 0; }
1329 sub calc_units  { 0; }
1330
1331 #fallback for everything not based on flat.pm
1332 sub recur_temporality { 'upcoming'; }
1333 sub calc_cancel { 0; }
1334
1335 #fallback for everything except bulk.pm
1336 sub hide_svc_detail { 0; }
1337
1338 #fallback for packages that can't/won't summarize usage
1339 sub sum_usage { 0; }
1340
1341 =item recur_cost_permonth CUST_PKG
1342
1343 recur_cost divided by freq (only supported for monthly and longer frequencies)
1344
1345 =cut
1346
1347 sub recur_cost_permonth {
1348   my($self, $cust_pkg) = @_;
1349   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1350   sprintf('%.2f', $self->recur_cost / $self->freq );
1351 }
1352
1353 =item cust_bill_pkg_recur CUST_PKG
1354
1355 Actual recurring charge for the specified customer package from customer's most
1356 recent invoice
1357
1358 =cut
1359
1360 sub cust_bill_pkg_recur {
1361   my($self, $cust_pkg) = @_;
1362   my $cust_bill_pkg = qsearchs({
1363     'table'     => 'cust_bill_pkg',
1364     'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )',
1365     'hashref'   => { 'pkgnum' => $cust_pkg->pkgnum,
1366                      'recur'  => { op=>'>', value=>'0' },
1367                    },
1368     'order_by'  => 'ORDER BY cust_bill._date     DESC,
1369                              cust_bill_pkg.sdate DESC
1370                      LIMIT 1
1371                    ',
1372   }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition";
1373   $cust_bill_pkg->recur;
1374 }
1375
1376 =item format OPTION DATA
1377
1378 Returns data formatted according to the function 'format' described
1379 in the plan info.  Returns DATA if no such function exists.
1380
1381 =cut
1382
1383 sub format {
1384   my ($self, $option, $data) = (shift, shift, shift);
1385   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1386     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1387   }else{
1388     $data;
1389   }
1390 }
1391
1392 =item parse OPTION DATA
1393
1394 Returns data parsed according to the function 'parse' described
1395 in the plan info.  Returns DATA if no such function exists.
1396
1397 =cut
1398
1399 sub parse {
1400   my ($self, $option, $data) = (shift, shift, shift);
1401   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1402     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1403   }else{
1404     $data;
1405   }
1406 }
1407
1408 =back
1409
1410 =cut
1411
1412 =head1 CLASS METHODS
1413
1414 =over 4
1415
1416 =cut
1417
1418 # _upgrade_data
1419 #
1420 # Used by FS::Upgrade to migrate to a new database.
1421
1422 sub _upgrade_data { # class method
1423   my($class, %opts) = @_;
1424
1425   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1426
1427   my @part_pkg = qsearch({
1428     'table'     => 'part_pkg',
1429     'extra_sql' => "WHERE ". join(' OR ',
1430                      ( map "($_ IS NOT NULL AND $_ != '' )",
1431                            qw( plandata setup recur ) ),
1432                      'plan IS NULL', "plan = '' ",
1433                    ),
1434   });
1435
1436   foreach my $part_pkg (@part_pkg) {
1437
1438     unless ( $part_pkg->plan ) {
1439       $part_pkg->plan('flat');
1440     }
1441
1442     if ( length($part_pkg->option('setup_fee')) == 0 
1443          && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) {
1444
1445       my $opt = new FS::part_pkg_option {
1446         'pkgpart'     => $part_pkg->pkgpart,
1447         'optionname'  => 'setup_fee',
1448         'optionvalue' => $1,
1449       };
1450       my $error = $opt->insert;
1451       die $error if $error;
1452
1453
1454       #} else {
1455       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1456       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1457     }
1458     $part_pkg->setup('');
1459
1460     if ( length($part_pkg->option('recur_fee')) == 0
1461          && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) {
1462
1463         my $opt = new FS::part_pkg_option {
1464           'pkgpart'     => $part_pkg->pkgpart,
1465           'optionname'  => 'recur_fee',
1466           'optionvalue' => $1,
1467         };
1468         my $error = $opt->insert;
1469         die $error if $error;
1470
1471
1472       #} else {
1473       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1474       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1475     }
1476     $part_pkg->recur('');
1477
1478     $part_pkg->replace; #this should take care of plandata, right?
1479
1480   }
1481
1482   # now upgrade to the explicit custom flag
1483
1484   @part_pkg = qsearch({
1485     'table'     => 'part_pkg',
1486     'hashref'   => { disabled => 'Y', custom => '' },
1487     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1488   });
1489
1490   foreach my $part_pkg (@part_pkg) {
1491     my $new = new FS::part_pkg { $part_pkg->hash };
1492     $new->custom('Y');
1493     my $comment = $part_pkg->comment;
1494     $comment =~ s/^\(CUSTOM\) //;
1495     $comment = '(none)' unless $comment =~ /\S/;
1496     $new->comment($comment);
1497
1498     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1499     my $primary = $part_pkg->svcpart;
1500     my $options = { $part_pkg->options };
1501
1502     my $error = $new->replace( $part_pkg,
1503                                'pkg_svc'     => $pkg_svc,
1504                                'primary_svc' => $primary,
1505                                'options'     => $options,
1506                              );
1507     die $error if $error;
1508   }
1509
1510   my @part_pkg_option = qsearch('part_pkg_option',
1511     { 'optionname'  => 'unused_credit',
1512       'optionvalue' => 1,
1513     });
1514   foreach my $old_opt (@part_pkg_option) {
1515     my $pkgpart = $old_opt->pkgpart;
1516     my $error = $old_opt->delete;
1517     die $error if $error;
1518
1519     foreach (qw(unused_credit_cancel unused_credit_change)) {
1520       my $new_opt = new FS::part_pkg_option {
1521         'pkgpart'     => $pkgpart,
1522         'optionname'  => $_,
1523         'optionvalue' => 1,
1524       };
1525       $error = $new_opt->insert;
1526       die $error if $error;
1527     }
1528   }
1529
1530   # migrate use_disposition_taqua and use_disposition to disposition_in
1531   @part_pkg_option = qsearch('part_pkg_option',
1532     { 'optionname'  => { op => 'LIKE',
1533                          value => 'use_disposition%',
1534                        },
1535       'optionvalue' => 1,
1536     });
1537   my %newopts = map { $_->pkgpart => $_ } 
1538     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1539   foreach my $old_opt (@part_pkg_option) {
1540         my $pkgpart = $old_opt->pkgpart;
1541         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1542                                                                   : 'ANSWERED';
1543         my $error = $old_opt->delete;
1544         die $error if $error;
1545
1546         if ( exists($newopts{$pkgpart}) ) {
1547             my $opt = $newopts{$pkgpart};
1548             $opt->optionvalue($opt->optionvalue.",$newval");
1549             $error = $opt->replace;
1550             die $error if $error;
1551         } else {
1552             my $new_opt = new FS::part_pkg_option {
1553                 'pkgpart'     => $pkgpart,
1554                 'optionname'  => 'disposition_in',
1555                 'optionvalue' => $newval,
1556               };
1557               $error = $new_opt->insert;
1558               die $error if $error;
1559               $newopts{$pkgpart} = $new_opt;
1560         }
1561   }
1562
1563   # set any package with FCC voice lines to the "VoIP with broadband" category
1564   # for backward compatibility
1565   #
1566   # recover from a bad upgrade bug
1567   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1568   if (!FS::upgrade_journal->is_done($upgrade)) {
1569     my $bad_upgrade = qsearchs('upgrade_journal', 
1570       { upgrade => 'part_pkg_fcc_voip_class' }
1571     );
1572     if ( $bad_upgrade ) {
1573       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1574                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1575       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1576         qsearch({
1577           'select'    => '*',
1578           'table'     => 'h_part_pkg_option',
1579           'hashref'   => {},
1580           'extra_sql' => "$where AND history_action = 'delete'",
1581           'order_by'  => 'ORDER BY history_date ASC',
1582         });
1583       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1584         qsearch({
1585           'select'    => '*',
1586           'table'     => 'h_pkg_svc',
1587           'hashref'   => {},
1588           'extra_sql' => "$where AND history_action = 'replace_old'",
1589           'order_by'  => 'ORDER BY history_date ASC',
1590         });
1591       my %opt;
1592       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1593         my $pkgpart ||= $deleted->pkgpart;
1594         $opt{$pkgpart} ||= {
1595           options => {},
1596           pkg_svc => {},
1597           primary_svc => '',
1598           hidden_svc => {},
1599         };
1600         if ( $deleted->isa('FS::part_pkg_option') ) {
1601           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1602         } else { # pkg_svc
1603           my $svcpart = $deleted->svcpart;
1604           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1605           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1606           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1607         }
1608       }
1609       foreach my $pkgpart (keys %opt) {
1610         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1611         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1612         if ( $error ) {
1613           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1614         }
1615       }
1616     } # $bad_upgrade exists
1617     else { # do the original upgrade, but correctly this time
1618       @part_pkg = qsearch('part_pkg', {
1619           fcc_ds0s        => { op => '>', value => 0 },
1620           fcc_voip_class  => ''
1621       });
1622       foreach my $part_pkg (@part_pkg) {
1623         $part_pkg->set(fcc_voip_class => 2);
1624         my @pkg_svc = $part_pkg->pkg_svc;
1625         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1626         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1627         my $error = $part_pkg->replace(
1628           $part_pkg->replace_old,
1629           options     => { $part_pkg->options },
1630           pkg_svc     => \%quantity,
1631           hidden_svc  => \%hidden,
1632           primary_svc => ($part_pkg->svcpart || ''),
1633         );
1634         die $error if $error;
1635       }
1636     }
1637     FS::upgrade_journal->set_done($upgrade);
1638   }
1639
1640 }
1641
1642 =item curuser_pkgs_sql
1643
1644 Returns an SQL fragment for searching for packages the current user can
1645 use, either via part_pkg.agentnum directly, or via agent type (see
1646 L<FS::type_pkgs>).
1647
1648 =cut
1649
1650 sub curuser_pkgs_sql {
1651   my $class = shift;
1652
1653   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1654
1655 }
1656
1657 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1658
1659 Returns an SQL fragment for searching for packages the provided agent or agents
1660 can use, either via part_pkg.agentnum directly, or via agent type (see
1661 L<FS::type_pkgs>).
1662
1663 =cut
1664
1665 sub agent_pkgs_sql {
1666   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1667   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1668
1669   $class->_pkgs_sql(@agentnums); #is this why
1670
1671 }
1672
1673 sub _pkgs_sql {
1674   my( $class, @agentnums ) = @_;
1675   my $agentnums = join(',', @agentnums);
1676
1677   "
1678     (
1679       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1680       OR ( agentnum IS NULL
1681            AND EXISTS ( SELECT 1
1682                           FROM type_pkgs
1683                             LEFT JOIN agent_type USING ( typenum )
1684                             LEFT JOIN agent AS typeagent USING ( typenum )
1685                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1686                             AND typeagent.agentnum IN ($agentnums)
1687                       )
1688          )
1689     )
1690   ";
1691
1692 }
1693
1694 =back
1695
1696 =head1 SUBROUTINES
1697
1698 =over 4
1699
1700 =item plan_info
1701
1702 =cut
1703
1704 #false laziness w/part_export & cdr
1705 my %info;
1706 foreach my $INC ( @INC ) {
1707   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1708   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1709     warn "attempting to load plan info from $file\n" if $DEBUG;
1710     $file =~ /\/(\w+)\.pm$/ or do {
1711       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1712       next;
1713     };
1714     my $mod = $1;
1715     my $info = eval "use FS::part_pkg::$mod; ".
1716                     "\\%FS::part_pkg::$mod\::info;";
1717     if ( $@ ) {
1718       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1719       next;
1720     }
1721     unless ( keys %$info ) {
1722       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1723       next;
1724     }
1725     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1726     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1727     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1728     #  next;
1729     #}
1730     $info{$mod} = $info;
1731     $info->{'weight'} ||= 0; # quiet warnings
1732   }
1733 }
1734
1735 # copy one level deep to allow replacement of fields and fieldorder
1736 tie %plans, 'Tie::IxHash',
1737   map  { my %infohash = %{ $info{$_} }; 
1738           $_ => \%infohash }
1739   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1740   keys %info;
1741
1742 # inheritance of plan options
1743 foreach my $name (keys(%info)) {
1744   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1745     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1746     delete $plans{$name};
1747     next;
1748   }
1749   my $parents = $info{$name}->{'inherit_fields'} || [];
1750   my (%fields, %field_exists, @fieldorder);
1751   foreach my $parent ($name, @$parents) {
1752     if ( !exists($info{$parent}) ) {
1753       warn "$name tried to inherit from nonexistent '$parent'\n";
1754       next;
1755     }
1756     %fields = ( # avoid replacing existing fields
1757       %{ $info{$parent}->{'fields'} || {} },
1758       %fields
1759     );
1760     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
1761       # avoid duplicates
1762       next if $field_exists{$_};
1763       $field_exists{$_} = 1;
1764       # allow inheritors to remove inherited fields from the fieldorder
1765       push @fieldorder, $_ if !exists($fields{$_}) or
1766                               !exists($fields{$_}->{'disabled'});
1767     }
1768   }
1769   $plans{$name}->{'fields'} = \%fields;
1770   $plans{$name}->{'fieldorder'} = \@fieldorder;
1771 }
1772
1773 sub plan_info {
1774   \%plans;
1775 }
1776
1777
1778 =back
1779
1780 =head1 NEW PLAN CLASSES
1781
1782 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1783 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1784 other modules in FS/FS/part_pkg/ as a guide.
1785
1786 =head1 BUGS
1787
1788 The delete method is unimplemented.
1789
1790 setup and recur semantics are not yet defined (and are implemented in
1791 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1792
1793 plandata should go
1794
1795 part_pkg_taxrate is Pg specific
1796
1797 replace should be smarter about managing the related tables (options, pkg_svc)
1798
1799 =head1 SEE ALSO
1800
1801 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1802 schema.html from the base documentation.
1803
1804 =cut
1805
1806 1;
1807