9e3b67ef1b603b286379706f4f1510b39c6d771c
[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 format OPTION DATA
1654
1655 Returns data formatted according to the function 'format' described
1656 in the plan info.  Returns DATA if no such function exists.
1657
1658 =cut
1659
1660 sub format {
1661   my ($self, $option, $data) = (shift, shift, shift);
1662   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1663     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1664   }else{
1665     $data;
1666   }
1667 }
1668
1669 =item parse OPTION DATA
1670
1671 Returns data parsed according to the function 'parse' described
1672 in the plan info.  Returns DATA if no such function exists.
1673
1674 =cut
1675
1676 sub parse {
1677   my ($self, $option, $data) = (shift, shift, shift);
1678   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1679     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1680   }else{
1681     $data;
1682   }
1683 }
1684
1685 =back
1686
1687 =cut
1688
1689 =head1 CLASS METHODS
1690
1691 =over 4
1692
1693 =cut
1694
1695 # _upgrade_data
1696 #
1697 # Used by FS::Upgrade to migrate to a new database.
1698
1699 sub _upgrade_data { # class method
1700   my($class, %opts) = @_;
1701
1702   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1703
1704   my @part_pkg = qsearch({
1705     'table'     => 'part_pkg',
1706     'extra_sql' => "WHERE ". join(' OR ',
1707                      'plan IS NULL', "plan = '' ",
1708                    ),
1709   });
1710
1711   foreach my $part_pkg (@part_pkg) {
1712
1713     unless ( $part_pkg->plan ) {
1714       $part_pkg->plan('flat');
1715     }
1716
1717     $part_pkg->replace;
1718
1719   }
1720
1721   # now upgrade to the explicit custom flag
1722
1723   @part_pkg = qsearch({
1724     'table'     => 'part_pkg',
1725     'hashref'   => { disabled => 'Y', custom => '' },
1726     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1727   });
1728
1729   foreach my $part_pkg (@part_pkg) {
1730     my $new = new FS::part_pkg { $part_pkg->hash };
1731     $new->custom('Y');
1732     my $comment = $part_pkg->comment;
1733     $comment =~ s/^\(CUSTOM\) //;
1734     $comment = '(none)' unless $comment =~ /\S/;
1735     $new->comment($comment);
1736
1737     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1738     my $primary = $part_pkg->svcpart;
1739     my $options = { $part_pkg->options };
1740
1741     my $error = $new->replace( $part_pkg,
1742                                'pkg_svc'     => $pkg_svc,
1743                                'primary_svc' => $primary,
1744                                'options'     => $options,
1745                              );
1746     die $error if $error;
1747   }
1748
1749   # set family_pkgpart on any packages that don't have it
1750   @part_pkg = qsearch('part_pkg', { 'family_pkgpart' => '' });
1751   foreach my $part_pkg (@part_pkg) {
1752     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
1753     my $error = $part_pkg->SUPER::replace;
1754     die $error if $error;
1755   }
1756
1757   my @part_pkg_option = qsearch('part_pkg_option',
1758     { 'optionname'  => 'unused_credit',
1759       'optionvalue' => 1,
1760     });
1761   foreach my $old_opt (@part_pkg_option) {
1762     my $pkgpart = $old_opt->pkgpart;
1763     my $error = $old_opt->delete;
1764     die $error if $error;
1765
1766     foreach (qw(unused_credit_cancel unused_credit_change)) {
1767       my $new_opt = new FS::part_pkg_option {
1768         'pkgpart'     => $pkgpart,
1769         'optionname'  => $_,
1770         'optionvalue' => 1,
1771       };
1772       $error = $new_opt->insert;
1773       die $error if $error;
1774     }
1775   }
1776
1777   # migrate use_disposition_taqua and use_disposition to disposition_in
1778   @part_pkg_option = qsearch('part_pkg_option',
1779     { 'optionname'  => { op => 'LIKE',
1780                          value => 'use_disposition%',
1781                        },
1782       'optionvalue' => 1,
1783     });
1784   my %newopts = map { $_->pkgpart => $_ } 
1785     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1786   foreach my $old_opt (@part_pkg_option) {
1787         my $pkgpart = $old_opt->pkgpart;
1788         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1789                                                                   : 'ANSWERED';
1790         my $error = $old_opt->delete;
1791         die $error if $error;
1792
1793         if ( exists($newopts{$pkgpart}) ) {
1794             my $opt = $newopts{$pkgpart};
1795             $opt->optionvalue($opt->optionvalue.",$newval");
1796             $error = $opt->replace;
1797             die $error if $error;
1798         } else {
1799             my $new_opt = new FS::part_pkg_option {
1800                 'pkgpart'     => $pkgpart,
1801                 'optionname'  => 'disposition_in',
1802                 'optionvalue' => $newval,
1803               };
1804               $error = $new_opt->insert;
1805               die $error if $error;
1806               $newopts{$pkgpart} = $new_opt;
1807         }
1808   }
1809
1810   # set any package with FCC voice lines to the "VoIP with broadband" category
1811   # for backward compatibility
1812   #
1813   # recover from a bad upgrade bug
1814   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1815   if (!FS::upgrade_journal->is_done($upgrade)) {
1816     my $bad_upgrade = qsearchs('upgrade_journal', 
1817       { upgrade => 'part_pkg_fcc_voip_class' }
1818     );
1819     if ( $bad_upgrade ) {
1820       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1821                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1822       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1823         qsearch({
1824           'select'    => '*',
1825           'table'     => 'h_part_pkg_option',
1826           'hashref'   => {},
1827           'extra_sql' => "$where AND history_action = 'delete'",
1828           'order_by'  => 'ORDER BY history_date ASC',
1829         });
1830       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1831         qsearch({
1832           'select'    => '*',
1833           'table'     => 'h_pkg_svc',
1834           'hashref'   => {},
1835           'extra_sql' => "$where AND history_action = 'replace_old'",
1836           'order_by'  => 'ORDER BY history_date ASC',
1837         });
1838       my %opt;
1839       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1840         my $pkgpart ||= $deleted->pkgpart;
1841         $opt{$pkgpart} ||= {
1842           options => {},
1843           pkg_svc => {},
1844           primary_svc => '',
1845           hidden_svc => {},
1846         };
1847         if ( $deleted->isa('FS::part_pkg_option') ) {
1848           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1849         } else { # pkg_svc
1850           my $svcpart = $deleted->svcpart;
1851           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1852           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1853           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1854         }
1855       }
1856       foreach my $pkgpart (keys %opt) {
1857         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1858         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1859         if ( $error ) {
1860           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1861         }
1862       }
1863     } # $bad_upgrade exists
1864     else { # do the original upgrade, but correctly this time
1865       @part_pkg = qsearch('part_pkg', {
1866           fcc_ds0s        => { op => '>', value => 0 },
1867           fcc_voip_class  => ''
1868       });
1869       foreach my $part_pkg (@part_pkg) {
1870         $part_pkg->set(fcc_voip_class => 2);
1871         my @pkg_svc = $part_pkg->pkg_svc;
1872         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1873         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1874         my $error = $part_pkg->replace(
1875           $part_pkg->replace_old,
1876           options     => { $part_pkg->options },
1877           pkg_svc     => \%quantity,
1878           hidden_svc  => \%hidden,
1879           primary_svc => ($part_pkg->svcpart || ''),
1880         );
1881         die $error if $error;
1882       }
1883     }
1884     FS::upgrade_journal->set_done($upgrade);
1885   }
1886
1887 }
1888
1889 =item curuser_pkgs_sql
1890
1891 Returns an SQL fragment for searching for packages the current user can
1892 use, either via part_pkg.agentnum directly, or via agent type (see
1893 L<FS::type_pkgs>).
1894
1895 =cut
1896
1897 sub curuser_pkgs_sql {
1898   my $class = shift;
1899
1900   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1901
1902 }
1903
1904 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1905
1906 Returns an SQL fragment for searching for packages the provided agent or agents
1907 can use, either via part_pkg.agentnum directly, or via agent type (see
1908 L<FS::type_pkgs>).
1909
1910 =cut
1911
1912 sub agent_pkgs_sql {
1913   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1914   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1915
1916   $class->_pkgs_sql(@agentnums); #is this why
1917
1918 }
1919
1920 sub _pkgs_sql {
1921   my( $class, @agentnums ) = @_;
1922   my $agentnums = join(',', @agentnums);
1923
1924   "
1925     (
1926       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1927       OR ( agentnum IS NULL
1928            AND EXISTS ( SELECT 1
1929                           FROM type_pkgs
1930                             LEFT JOIN agent_type USING ( typenum )
1931                             LEFT JOIN agent AS typeagent USING ( typenum )
1932                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1933                             AND typeagent.agentnum IN ($agentnums)
1934                       )
1935          )
1936     )
1937   ";
1938
1939 }
1940
1941 =back
1942
1943 =head1 SUBROUTINES
1944
1945 =over 4
1946
1947 =item plan_info
1948
1949 =cut
1950
1951 #false laziness w/part_export & cdr
1952 my %info;
1953 foreach my $INC ( @INC ) {
1954   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1955   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1956     warn "attempting to load plan info from $file\n" if $DEBUG;
1957     $file =~ /\/(\w+)\.pm$/ or do {
1958       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1959       next;
1960     };
1961     my $mod = $1;
1962     my $info = eval "use FS::part_pkg::$mod; ".
1963                     "\\%FS::part_pkg::$mod\::info;";
1964     if ( $@ ) {
1965       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1966       next;
1967     }
1968     unless ( keys %$info ) {
1969       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1970       next;
1971     }
1972     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1973     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1974     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1975     #  next;
1976     #}
1977     $info{$mod} = $info;
1978     $info->{'weight'} ||= 0; # quiet warnings
1979   }
1980 }
1981
1982 # copy one level deep to allow replacement of fields and fieldorder
1983 tie %plans, 'Tie::IxHash',
1984   map  { my %infohash = %{ $info{$_} }; 
1985           $_ => \%infohash }
1986   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1987   keys %info;
1988
1989 # inheritance of plan options
1990 foreach my $name (keys(%info)) {
1991   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1992     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1993     delete $plans{$name};
1994     next;
1995   }
1996   my $parents = $info{$name}->{'inherit_fields'} || [];
1997   my (%fields, %field_exists, @fieldorder);
1998   foreach my $parent ($name, @$parents) {
1999     if ( !exists($info{$parent}) ) {
2000       warn "$name tried to inherit from nonexistent '$parent'\n";
2001       next;
2002     }
2003     %fields = ( # avoid replacing existing fields
2004       %{ $info{$parent}->{'fields'} || {} },
2005       %fields
2006     );
2007     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
2008       # avoid duplicates
2009       next if $field_exists{$_};
2010       $field_exists{$_} = 1;
2011       # allow inheritors to remove inherited fields from the fieldorder
2012       push @fieldorder, $_ if !exists($fields{$_}) or
2013                               !exists($fields{$_}->{'disabled'});
2014     }
2015   }
2016   $plans{$name}->{'fields'} = \%fields;
2017   $plans{$name}->{'fieldorder'} = \@fieldorder;
2018 }
2019
2020 sub plan_info {
2021   \%plans;
2022 }
2023
2024
2025 =back
2026
2027 =head1 NEW PLAN CLASSES
2028
2029 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
2030 found in eg/plan_template.pm.  Until then, it is suggested that you use the
2031 other modules in FS/FS/part_pkg/ as a guide.
2032
2033 =head1 BUGS
2034
2035 The delete method is unimplemented.
2036
2037 setup and recur semantics are not yet defined (and are implemented in
2038 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
2039
2040 plandata should go
2041
2042 part_pkg_taxrate is Pg specific
2043
2044 replace should be smarter about managing the related tables (options, pkg_svc)
2045
2046 =head1 SEE ALSO
2047
2048 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
2049 schema.html from the base documentation.
2050
2051 =cut
2052
2053 1;
2054