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