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