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