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