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