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