allow commissions based on setup fee, #23969, fallout from #6991
[freeside.git] / FS / FS / part_pkg.pm
1 package FS::part_pkg;
2 use base qw( FS::m2m_Common FS::o2m_Common FS::option_Common );
3
4 use strict;
5 use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
6 use Carp qw(carp cluck confess);
7 use Scalar::Util qw( blessed );
8 use DateTime;
9 use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with DateTime
10 use Tie::IxHash;
11 use FS::Conf;
12 use FS::Record qw( qsearch qsearchs dbh dbdef );
13 use FS::pkg_svc;
14 use FS::part_svc;
15 use FS::cust_pkg;
16 use FS::agent_type;
17 use FS::type_pkgs;
18 use FS::part_pkg_option;
19 use FS::pkg_class;
20 use FS::agent;
21 use FS::part_pkg_msgcat;
22 use FS::part_pkg_taxrate;
23 use FS::part_pkg_taxoverride;
24 use FS::part_pkg_taxproduct;
25 use FS::part_pkg_link;
26 use FS::part_pkg_discount;
27 use FS::part_pkg_usage;
28 use FS::part_pkg_vendor;
29
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_text('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->SUPER::check
640   ;
641   return $error if $error;
642
643   return 'Unknown plan '. $self->plan
644     unless exists($plans{$self->plan});
645
646   my $conf = new FS::Conf;
647   return 'Taxclass is required'
648     if ! $self->taxclass && $conf->exists('require_taxclasses');
649
650   '';
651 }
652
653 =item supersede OLD [, OPTION => VALUE ... ]
654
655 Inserts this package as a successor to the package OLD.  All options are as
656 for C<insert>.  After inserting, disables OLD and sets the new package as its
657 successor.
658
659 =cut
660
661 sub supersede {
662   my ($new, $old, %options) = @_;
663   my $error;
664
665   $new->set('pkgpart' => '');
666   $new->set('family_pkgpart' => $old->family_pkgpart);
667   warn "    inserting successor package\n" if $DEBUG;
668   $error = $new->insert(%options);
669   return $error if $error;
670  
671   warn "    disabling superseded package\n" if $DEBUG; 
672   $old->set('successor' => $new->pkgpart);
673   $old->set('disabled' => 'Y');
674   $error = $old->SUPER::replace; # don't change its options/pkg_svc records
675   return $error if $error;
676
677   warn "  propagating changes to family" if $DEBUG;
678   $new->propagate($old);
679 }
680
681 =item propagate OLD
682
683 If any of certain fields have changed from OLD to this package, then,
684 for all packages in the same lineage as this one, sets those fields 
685 to their values in this package.
686
687 =cut
688
689 my @propagate_fields = (
690   qw( pkg classnum setup_cost recur_cost taxclass
691   setuptax recurtax pay_weight credit_weight
692   )
693 );
694
695 sub propagate {
696   my $new = shift;
697   my $old = shift;
698   my %fields = (
699     map { $_ => $new->get($_) }
700     grep { $new->get($_) ne $old->get($_) }
701     @propagate_fields
702   );
703
704   my @part_pkg = qsearch('part_pkg', { 
705       'family_pkgpart' => $new->family_pkgpart 
706   });
707   my @error;
708   foreach my $part_pkg ( @part_pkg ) {
709     my $pkgpart = $part_pkg->pkgpart;
710     next if $pkgpart == $new->pkgpart; # don't modify $new
711     warn "    propagating to pkgpart $pkgpart\n" if $DEBUG;
712     foreach ( keys %fields ) {
713       $part_pkg->set($_, $fields{$_});
714     }
715     # SUPER::replace to avoid changing non-core fields
716     my $error = $part_pkg->SUPER::replace;
717     push @error, "pkgpart $pkgpart: $error"
718       if $error;
719   }
720   join("\n", @error);
721 }
722
723 =item pkg_locale LOCALE
724
725 Returns a customer-viewable string representing this package for the given
726 locale, from the part_pkg_msgcat table.  If the given locale is empty or no
727 localized string is found, returns the base pkg field.
728
729 =cut
730
731 sub pkg_locale {
732   my( $self, $locale ) = @_;
733   return $self->pkg unless $locale;
734   my $part_pkg_msgcat = $self->part_pkg_msgcat($locale) or return $self->pkg;
735   $part_pkg_msgcat->pkg;
736 }
737
738 =item part_pkg_msgcat LOCALE
739
740 Like pkg_locale, but returns the FS::part_pkg_msgcat object itself.
741
742 =cut
743
744 sub part_pkg_msgcat {
745   my( $self, $locale ) = @_;
746   qsearchs( 'part_pkg_msgcat', {
747     pkgpart => $self->pkgpart,
748     locale  => $locale,
749   });
750 }
751
752 =item pkg_comment [ OPTION => VALUE... ]
753
754 Returns an (internal) string representing this package.  Currently,
755 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
756 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
757 custom is Y.
758
759 If the option nopkgpart is true then the "pkgpart: ' is omitted.
760
761 =cut
762
763 sub pkg_comment {
764   my $self = shift;
765   my %opt = @_;
766
767   #$self->pkg. ' - '. $self->comment;
768   #$self->pkg. ' ('. $self->comment. ')';
769   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
770   $pre. $self->pkg. ' - '. $self->custom_comment;
771 }
772
773 sub price_info { # safety, in case a part_pkg hasn't defined price_info
774     '';
775 }
776
777 sub custom_comment {
778   my $self = shift;
779   ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info;
780 }
781
782 =item pkg_class
783
784 Returns the package class, as an FS::pkg_class object, or the empty string
785 if there is no package class.
786
787 =cut
788
789 sub pkg_class {
790   my $self = shift;
791   if ( $self->classnum ) {
792     qsearchs('pkg_class', { 'classnum' => $self->classnum } );
793   } else {
794     return '';
795   }
796 }
797
798 =item addon_pkg_class
799
800 Returns the add-on package class, as an FS::pkg_class object, or the empty
801 string if there is no add-on package class.
802
803 =cut
804
805 sub addon_pkg_class {
806   my $self = shift;
807   if ( $self->addon_classnum ) {
808     qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } );
809   } else {
810     return '';
811   }
812 }
813
814 =item categoryname 
815
816 Returns the package category name, or the empty string if there is no package
817 category.
818
819 =cut
820
821 sub categoryname {
822   my $self = shift;
823   my $pkg_class = $self->pkg_class;
824   $pkg_class
825     ? $pkg_class->categoryname
826     : '';
827 }
828
829 =item classname 
830
831 Returns the package class name, or the empty string if there is no package
832 class.
833
834 =cut
835
836 sub classname {
837   my $self = shift;
838   my $pkg_class = $self->pkg_class;
839   $pkg_class
840     ? $pkg_class->classname
841     : '';
842 }
843
844 =item addon_classname 
845
846 Returns the add-on package class name, or the empty string if there is no
847 add-on package class.
848
849 =cut
850
851 sub addon_classname {
852   my $self = shift;
853   my $pkg_class = $self->addon_pkg_class;
854   $pkg_class
855     ? $pkg_class->classname
856     : '';
857 }
858
859 =item agent 
860
861 Returns the associated agent for this event, if any, as an FS::agent object.
862
863 =cut
864
865 sub agent {
866   my $self = shift;
867   qsearchs('agent', { 'agentnum' => $self->agentnum } );
868 }
869
870 =item pkg_svc [ HASHREF | OPTION => VALUE ]
871
872 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
873 definition (with non-zero quantity).
874
875 One option is available, I<disable_linked>.  If set true it will return the
876 services for this package definition alone, omitting services from any add-on
877 packages.
878
879 =cut
880
881 =item type_pkgs
882
883 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
884 definition.
885
886 =cut
887
888 sub type_pkgs {
889   my $self = shift;
890   qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
891 }
892
893 sub pkg_svc {
894   my $self = shift;
895
896 #  #sort { $b->primary cmp $a->primary } 
897 #    grep { $_->quantity }
898 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
899
900   my $opt = ref($_[0]) ? $_[0] : { @_ };
901   my %pkg_svc = map  { $_->svcpart => $_ }
902                 grep { $_->quantity }
903                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
904
905   unless ( $opt->{disable_linked} ) {
906     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
907       my @pkg_svc = grep { $_->quantity }
908                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
909       foreach my $pkg_svc ( @pkg_svc ) {
910         if ( $pkg_svc{$pkg_svc->svcpart} ) {
911           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
912           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
913         } else {
914           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
915         }
916       }
917     }
918   }
919
920   values(%pkg_svc);
921
922 }
923
924 =item svcpart [ SVCDB ]
925
926 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
927 associated with this package definition (see L<FS::pkg_svc>).  Returns
928 false if there not a primary service definition or exactly one service
929 definition with quantity 1, or if SVCDB is specified and does not match the
930 svcdb of the service definition.  SVCDB can be specified as a scalar table
931 name, such as 'svc_acct', or as an arrayref of possible table names.
932
933 =cut
934
935 sub svcpart {
936   my $pkg_svc = shift->_primary_pkg_svc(@_);
937   $pkg_svc ? $pkg_svc->svcpart : '';
938 }
939
940 =item part_svc [ SVCDB ]
941
942 Like the B<svcpart> method, but returns the FS::part_svc object (see
943 L<FS::part_svc>).
944
945 =cut
946
947 sub part_svc {
948   my $pkg_svc = shift->_primary_pkg_svc(@_);
949   $pkg_svc ? $pkg_svc->part_svc : '';
950 }
951
952 sub _primary_pkg_svc {
953   my $self = shift;
954
955   my $svcdb = scalar(@_) ? shift : [];
956   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
957   my %svcdb = map { $_=>1 } @$svcdb;
958
959   my @svcdb_pkg_svc =
960     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
961          $self->pkg_svc;
962
963   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
964   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
965     unless @pkg_svc;
966   return '' if scalar(@pkg_svc) != 1;
967   $pkg_svc[0];
968 }
969
970 =item svcpart_unique_svcdb SVCDB
971
972 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
973 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
974 false if there not a primary service definition for SVCDB or there are multiple
975 service definitions for SVCDB.
976
977 =cut
978
979 sub svcpart_unique_svcdb {
980   my( $self, $svcdb ) = @_;
981   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
982   return '' if scalar(@svcdb_pkg_svc) != 1;
983   $svcdb_pkg_svc[0]->svcpart;
984 }
985
986 =item payby
987
988 Returns a list of the acceptable payment types for this package.  Eventually
989 this should come out of a database table and be editable, but currently has the
990 following logic instead:
991
992 If the package is free, the single item B<BILL> is
993 returned, otherwise, the single item B<CARD> is returned.
994
995 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
996
997 =cut
998
999 sub payby {
1000   my $self = shift;
1001   if ( $self->is_free ) {
1002     ( 'BILL' );
1003   } else {
1004     ( 'CARD' );
1005   }
1006 }
1007
1008 =item is_free
1009
1010 Returns true if this package is free.  
1011
1012 =cut
1013
1014 sub is_free {
1015   my $self = shift;
1016   if ( $self->can('is_free_options') ) {
1017     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
1018          map { $self->option($_) } 
1019              $self->is_free_options;
1020   } else {
1021     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
1022          "provides neither is_free_options nor is_free method; returning false";
1023     0;
1024   }
1025 }
1026
1027 # whether the plan allows discounts to be applied to this package
1028 sub can_discount { 0; }
1029
1030 # whether the plan allows changing the start date
1031 sub can_start_date { 1; }
1032
1033 # the delay start date if present
1034 sub delay_start_date {
1035   my $self = shift;
1036
1037   my $delay = $self->delay_start or return '';
1038
1039   # avoid timelocal silliness  
1040   my $dt = DateTime->today(time_zone => 'local');
1041   $dt->add(days => $delay);
1042   $dt->epoch;
1043 }
1044
1045 sub freqs_href {
1046   # moved to FS::Misc to make this accessible to other packages
1047   # at initialization
1048   FS::Misc::pkg_freqs();
1049 }
1050
1051 =item freq_pretty
1052
1053 Returns an english representation of the I<freq> field, such as "monthly",
1054 "weekly", "semi-annually", etc.
1055
1056 =cut
1057
1058 sub freq_pretty {
1059   my $self = shift;
1060   my $freq = $self->freq;
1061
1062   #my $freqs_href = $self->freqs_href;
1063   my $freqs_href = freqs_href();
1064
1065   if ( exists($freqs_href->{$freq}) ) {
1066     $freqs_href->{$freq};
1067   } else {
1068     my $interval = 'month';
1069     if ( $freq =~ /^(\d+)([hdw])$/ ) {
1070       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
1071       $interval = $interval{$2};
1072     }
1073     if ( $1 == 1 ) {
1074       "every $interval";
1075     } else {
1076       "every $freq ${interval}s";
1077     }
1078   }
1079 }
1080
1081 =item add_freq TIMESTAMP [ FREQ ]
1082
1083 Adds a billing period of some frequency to the provided timestamp and 
1084 returns the resulting timestamp, or -1 if the frequency could not be 
1085 parsed (shouldn't happen).  By default, the frequency of this package 
1086 will be used; to override this, pass a different frequency as a second 
1087 argument.
1088
1089 =cut
1090
1091 sub add_freq {
1092   my( $self, $date, $freq ) = @_;
1093   $freq = $self->freq unless $freq;
1094
1095   #change this bit to use Date::Manip? CAREFUL with timezones (see
1096   # mailing list archive)
1097   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
1098
1099   if ( $freq =~ /^\d+$/ ) {
1100     $mon += $freq;
1101     until ( $mon < 12 ) { $mon -= 12; $year++; }
1102
1103     $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback');
1104
1105   } elsif ( $freq =~ /^(\d+)w$/ ) {
1106     my $weeks = $1;
1107     $mday += $weeks * 7;
1108   } elsif ( $freq =~ /^(\d+)d$/ ) {
1109     my $days = $1;
1110     $mday += $days;
1111   } elsif ( $freq =~ /^(\d+)h$/ ) {
1112     my $hours = $1;
1113     $hour += $hours;
1114   } else {
1115     return -1;
1116   }
1117
1118   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
1119 }
1120
1121 =item plandata
1122
1123 For backwards compatibility, returns the plandata field as well as all options
1124 from FS::part_pkg_option.
1125
1126 =cut
1127
1128 sub plandata {
1129   my $self = shift;
1130   carp "plandata is deprecated";
1131   if ( @_ ) {
1132     $self->SUPER::plandata(@_);
1133   } else {
1134     my $plandata = $self->get('plandata');
1135     my %options = $self->options;
1136     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
1137     $plandata;
1138   }
1139 }
1140
1141 =item part_pkg_vendor
1142
1143 Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
1144 L<FS::part_pkg_vendor>).
1145
1146 =cut
1147
1148 sub part_pkg_vendor {
1149   my $self = shift;
1150   qsearch('part_pkg_vendor', { 'pkgpart' => $self->pkgpart } );
1151 }
1152
1153 =item vendor_pkg_ids
1154
1155 Returns a list of vendor/external package ids by exportnum
1156
1157 =cut
1158
1159 sub vendor_pkg_ids {
1160   my $self = shift;
1161   map { $_->exportnum => $_->vendor_pkg_id } $self->part_pkg_vendor;
1162 }
1163
1164 =item part_pkg_option
1165
1166 Returns all options as FS::part_pkg_option objects (see
1167 L<FS::part_pkg_option>).
1168
1169 =cut
1170
1171 sub part_pkg_option {
1172   my $self = shift;
1173   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
1174 }
1175
1176 =item options 
1177
1178 Returns a list of option names and values suitable for assigning to a hash.
1179
1180 =cut
1181
1182 sub options {
1183   my $self = shift;
1184   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
1185 }
1186
1187 =item option OPTIONNAME [ QUIET ]
1188
1189 Returns the option value for the given name, or the empty string.  If a true
1190 value is passed as the second argument, warnings about missing the option
1191 will be suppressed.
1192
1193 =cut
1194
1195 sub option {
1196   my( $self, $opt, $ornull ) = @_;
1197   my $part_pkg_option =
1198     qsearchs('part_pkg_option', {
1199       pkgpart    => $self->pkgpart,
1200       optionname => $opt,
1201   } );
1202   return $part_pkg_option->optionvalue if $part_pkg_option;
1203   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
1204                      split("\n", $self->get('plandata') );
1205   return $plandata{$opt} if exists $plandata{$opt};
1206   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
1207         "not found in options or plandata!\n"
1208     unless $ornull;
1209   '';
1210 }
1211
1212 =item bill_part_pkg_link
1213
1214 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1215
1216 =cut
1217
1218 sub bill_part_pkg_link {
1219   shift->_part_pkg_link('bill', @_);
1220 }
1221
1222 =item svc_part_pkg_link
1223
1224 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
1225
1226 =cut
1227
1228 sub svc_part_pkg_link {
1229   shift->_part_pkg_link('svc', @_);
1230 }
1231
1232 =item supp_part_pkg_link
1233
1234 Returns the associated part_pkg_link records of type 'supp' (supplemental
1235 packages).
1236
1237 =cut
1238
1239 sub supp_part_pkg_link {
1240   shift->_part_pkg_link('supp', @_);
1241 }
1242
1243 sub _part_pkg_link {
1244   my( $self, $type ) = @_;
1245   qsearch({ table    => 'part_pkg_link',
1246             hashref  => { 'src_pkgpart' => $self->pkgpart,
1247                           'link_type'   => $type,
1248                           #protection against infinite recursive links
1249                           'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
1250                         },
1251             order_by => "ORDER BY hidden",
1252          });
1253 }
1254
1255 sub self_and_bill_linked {
1256   shift->_self_and_linked('bill', @_);
1257 }
1258
1259 sub self_and_svc_linked {
1260   shift->_self_and_linked('svc', @_);
1261 }
1262
1263 sub _self_and_linked {
1264   my( $self, $type, $hidden ) = @_;
1265   $hidden ||= '';
1266
1267   my @result = ();
1268   foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) }
1269                      $self->_part_pkg_link($type) ) )
1270   {
1271     $_->hidden($hidden) if $hidden;
1272     push @result, $_;
1273   }
1274
1275   (@result);
1276 }
1277
1278 =item part_pkg_taxoverride [ CLASS ]
1279
1280 Returns all associated FS::part_pkg_taxoverride objects (see
1281 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
1282 of class CLASS if defined.  Class may be one of 'setup', 'recur',
1283 the empty string (default), or a usage class number (see L<FS::usage_class>).
1284 When a class is specified, the empty string class (default) is returned
1285 if no more specific values exist.
1286
1287 =cut
1288
1289 sub part_pkg_taxoverride {
1290   my $self = shift;
1291   my $class = shift;
1292
1293   my $hashref = { 'pkgpart' => $self->pkgpart };
1294   $hashref->{'usage_class'} = $class if defined($class);
1295   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
1296
1297   unless ( scalar(@overrides) || !defined($class) || !$class ){
1298     $hashref->{'usage_class'} = '';
1299     @overrides = qsearch('part_pkg_taxoverride', $hashref );
1300   }
1301
1302   @overrides;
1303 }
1304
1305 =item has_taxproduct
1306
1307 Returns true if this package has any taxproduct associated with it.  
1308
1309 =cut
1310
1311 sub has_taxproduct {
1312   my $self = shift;
1313
1314   $self->taxproductnum ||
1315   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
1316           keys %{ {$self->options} }
1317   )
1318
1319 }
1320
1321
1322 =item taxproduct [ CLASS ]
1323
1324 Returns the associated tax product for this package definition (see
1325 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
1326 the usage classnum (see L<FS::usage_class>).  Returns the default
1327 tax product for this record if the more specific CLASS value does
1328 not exist.
1329
1330 =cut
1331
1332 sub taxproduct {
1333   my $self = shift;
1334   my $class = shift;
1335
1336   my $part_pkg_taxproduct;
1337
1338   my $taxproductnum = $self->taxproductnum;
1339   if ($class) { 
1340     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1341     $taxproductnum = $class_taxproductnum
1342       if $class_taxproductnum
1343   }
1344   
1345   $part_pkg_taxproduct =
1346     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1347
1348   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1349     $taxproductnum = $self->taxproductnum;
1350     $part_pkg_taxproduct =
1351       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1352   }
1353
1354   $part_pkg_taxproduct;
1355 }
1356
1357 =item taxproduct_description [ CLASS ]
1358
1359 Returns the description of the associated tax product for this package
1360 definition (see L<FS::part_pkg_taxproduct>).
1361
1362 =cut
1363
1364 sub taxproduct_description {
1365   my $self = shift;
1366   my $part_pkg_taxproduct = $self->taxproduct(@_);
1367   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1368 }
1369
1370 =item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
1371
1372 Returns the package to taxrate m2m records for this package in the location
1373 specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
1374 CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
1375 (see L<FS::usage_class>).
1376
1377 =cut
1378
1379 sub _expand_cch_taxproductnum {
1380   my $self = shift;
1381   my $class = shift;
1382   my $part_pkg_taxproduct = $self->taxproduct($class);
1383
1384   my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
1385                          ? ( split ':', $part_pkg_taxproduct->taxproduct )
1386                          : ()
1387                      );
1388   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
1389   my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
1390                       OR taxproduct = '$a:$b:$c:'
1391                       OR taxproduct = '$a:$b:".":$d'
1392                       OR taxproduct = '$a:$b:".":' )";
1393   map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
1394                                        'hashref'   => { 'data_vendor'=>'cch' },
1395                                        'extra_sql' => $extra_sql,
1396                                    } );
1397                                      
1398 }
1399
1400 sub part_pkg_taxrate {
1401   my $self = shift;
1402   my ($data_vendor, $geocode, $class) = @_;
1403
1404   my $dbh = dbh;
1405   my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
1406                   dbh->quote($data_vendor);
1407   
1408   # CCH oddness in m2m
1409   $extra_sql .= ' AND ('.
1410     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
1411                  qw(10 5 2)
1412         ).
1413     ')';
1414   # much more CCH oddness in m2m -- this is kludgy
1415   my @tpnums = $self->_expand_cch_taxproductnum($class);
1416   if (scalar(@tpnums)) {
1417     $extra_sql .= ' AND ('.
1418                             join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
1419                        ')';
1420   } else {
1421     $extra_sql .= ' AND ( 0 = 1 )';
1422   }
1423
1424   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
1425   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
1426   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
1427
1428   # should qsearch preface columns with the table to facilitate joins?
1429   qsearch( { 'table'     => 'part_pkg_taxrate',
1430              'select'    => $select,
1431              'hashref'   => { # 'data_vendor'   => $data_vendor,
1432                               # 'taxproductnum' => $self->taxproductnum,
1433                             },
1434              'addl_from' => $addl_from,
1435              'extra_sql' => $extra_sql,
1436              'order_by'  => $order_by,
1437          } );
1438 }
1439
1440 =item part_pkg_discount
1441
1442 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
1443 for this package.
1444
1445 =cut
1446
1447 sub part_pkg_discount {
1448   my $self = shift;
1449   qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
1450 }
1451
1452 =item part_pkg_usage
1453
1454 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
1455 this package.
1456
1457 =cut
1458
1459 sub part_pkg_usage {
1460   my $self = shift;
1461   qsearch('part_pkg_usage', { 'pkgpart' => $self->pkgpart });
1462 }
1463
1464 =item _rebless
1465
1466 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1467 PLAN is the object's I<plan> field.  There should be better docs
1468 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1469
1470 =cut
1471
1472 sub _rebless {
1473   my $self = shift;
1474   my $plan = $self->plan;
1475   unless ( $plan ) {
1476     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1477       if $DEBUG;
1478     return $self;
1479   }
1480   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1481   my $class = ref($self). "::$plan";
1482   warn "reblessing $self into $class" if $DEBUG > 1;
1483   eval "use $class;";
1484   die $@ if $@;
1485   bless($self, $class) unless $@;
1486   $self;
1487 }
1488
1489 #fatal fallbacks
1490 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
1491 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
1492
1493 #fallback that return 0 for old legacy packages with no plan
1494 sub calc_remain { 0; }
1495 sub calc_units  { 0; }
1496
1497 #fallback for everything not based on flat.pm
1498 sub recur_temporality { 'upcoming'; }
1499 sub calc_cancel { 0; }
1500
1501 #fallback for everything except bulk.pm
1502 sub hide_svc_detail { 0; }
1503
1504 #fallback for packages that can't/won't summarize usage
1505 sub sum_usage { 0; }
1506
1507 =item recur_cost_permonth CUST_PKG
1508
1509 recur_cost divided by freq (only supported for monthly and longer frequencies)
1510
1511 =cut
1512
1513 sub recur_cost_permonth {
1514   my($self, $cust_pkg) = @_;
1515   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
1516   sprintf('%.2f', $self->recur_cost / $self->freq );
1517 }
1518
1519 =item cust_bill_pkg_recur CUST_PKG
1520
1521 Actual recurring charge for the specified customer package from customer's most
1522 recent invoice
1523
1524 =cut
1525
1526 sub cust_bill_pkg_recur {
1527   my($self, $cust_pkg) = @_;
1528   my $cust_bill_pkg = qsearchs({
1529     'table'     => 'cust_bill_pkg',
1530     'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )',
1531     'hashref'   => { 'pkgnum' => $cust_pkg->pkgnum,
1532                      'recur'  => { op=>'>', value=>'0' },
1533                    },
1534     'order_by'  => 'ORDER BY cust_bill._date     DESC,
1535                              cust_bill_pkg.sdate DESC
1536                      LIMIT 1
1537                    ',
1538   }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition";
1539   $cust_bill_pkg->recur;
1540 }
1541
1542 =item unit_setup CUST_PKG
1543
1544 Returns the setup fee for one unit of the package.
1545
1546 =cut
1547
1548 sub unit_setup {
1549   my ($self, $cust_pkg) = @_;
1550   $self->option('setup_fee') || 0;
1551 }
1552
1553 =item format OPTION DATA
1554
1555 Returns data formatted according to the function 'format' described
1556 in the plan info.  Returns DATA if no such function exists.
1557
1558 =cut
1559
1560 sub format {
1561   my ($self, $option, $data) = (shift, shift, shift);
1562   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1563     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1564   }else{
1565     $data;
1566   }
1567 }
1568
1569 =item parse OPTION DATA
1570
1571 Returns data parsed according to the function 'parse' described
1572 in the plan info.  Returns DATA if no such function exists.
1573
1574 =cut
1575
1576 sub parse {
1577   my ($self, $option, $data) = (shift, shift, shift);
1578   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1579     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1580   }else{
1581     $data;
1582   }
1583 }
1584
1585 =back
1586
1587 =cut
1588
1589 =head1 CLASS METHODS
1590
1591 =over 4
1592
1593 =cut
1594
1595 # _upgrade_data
1596 #
1597 # Used by FS::Upgrade to migrate to a new database.
1598
1599 sub _upgrade_data { # class method
1600   my($class, %opts) = @_;
1601
1602   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1603
1604   my @part_pkg = qsearch({
1605     'table'     => 'part_pkg',
1606     'extra_sql' => "WHERE ". join(' OR ',
1607                      'plan IS NULL', "plan = '' ",
1608                    ),
1609   });
1610
1611   foreach my $part_pkg (@part_pkg) {
1612
1613     unless ( $part_pkg->plan ) {
1614       $part_pkg->plan('flat');
1615     }
1616
1617     $part_pkg->replace;
1618
1619   }
1620
1621   # now upgrade to the explicit custom flag
1622
1623   @part_pkg = qsearch({
1624     'table'     => 'part_pkg',
1625     'hashref'   => { disabled => 'Y', custom => '' },
1626     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1627   });
1628
1629   foreach my $part_pkg (@part_pkg) {
1630     my $new = new FS::part_pkg { $part_pkg->hash };
1631     $new->custom('Y');
1632     my $comment = $part_pkg->comment;
1633     $comment =~ s/^\(CUSTOM\) //;
1634     $comment = '(none)' unless $comment =~ /\S/;
1635     $new->comment($comment);
1636
1637     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1638     my $primary = $part_pkg->svcpart;
1639     my $options = { $part_pkg->options };
1640
1641     my $error = $new->replace( $part_pkg,
1642                                'pkg_svc'     => $pkg_svc,
1643                                'primary_svc' => $primary,
1644                                'options'     => $options,
1645                              );
1646     die $error if $error;
1647   }
1648
1649   # set family_pkgpart on any packages that don't have it
1650   @part_pkg = qsearch('part_pkg', { 'family_pkgpart' => '' });
1651   foreach my $part_pkg (@part_pkg) {
1652     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
1653     my $error = $part_pkg->SUPER::replace;
1654     die $error if $error;
1655   }
1656
1657   my @part_pkg_option = qsearch('part_pkg_option',
1658     { 'optionname'  => 'unused_credit',
1659       'optionvalue' => 1,
1660     });
1661   foreach my $old_opt (@part_pkg_option) {
1662     my $pkgpart = $old_opt->pkgpart;
1663     my $error = $old_opt->delete;
1664     die $error if $error;
1665
1666     foreach (qw(unused_credit_cancel unused_credit_change)) {
1667       my $new_opt = new FS::part_pkg_option {
1668         'pkgpart'     => $pkgpart,
1669         'optionname'  => $_,
1670         'optionvalue' => 1,
1671       };
1672       $error = $new_opt->insert;
1673       die $error if $error;
1674     }
1675   }
1676
1677   # migrate use_disposition_taqua and use_disposition to disposition_in
1678   @part_pkg_option = qsearch('part_pkg_option',
1679     { 'optionname'  => { op => 'LIKE',
1680                          value => 'use_disposition%',
1681                        },
1682       'optionvalue' => 1,
1683     });
1684   my %newopts = map { $_->pkgpart => $_ } 
1685     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1686   foreach my $old_opt (@part_pkg_option) {
1687         my $pkgpart = $old_opt->pkgpart;
1688         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1689                                                                   : 'ANSWERED';
1690         my $error = $old_opt->delete;
1691         die $error if $error;
1692
1693         if ( exists($newopts{$pkgpart}) ) {
1694             my $opt = $newopts{$pkgpart};
1695             $opt->optionvalue($opt->optionvalue.",$newval");
1696             $error = $opt->replace;
1697             die $error if $error;
1698         } else {
1699             my $new_opt = new FS::part_pkg_option {
1700                 'pkgpart'     => $pkgpart,
1701                 'optionname'  => 'disposition_in',
1702                 'optionvalue' => $newval,
1703               };
1704               $error = $new_opt->insert;
1705               die $error if $error;
1706               $newopts{$pkgpart} = $new_opt;
1707         }
1708   }
1709
1710   # set any package with FCC voice lines to the "VoIP with broadband" category
1711   # for backward compatibility
1712   #
1713   # recover from a bad upgrade bug
1714   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1715   if (!FS::upgrade_journal->is_done($upgrade)) {
1716     my $bad_upgrade = qsearchs('upgrade_journal', 
1717       { upgrade => 'part_pkg_fcc_voip_class' }
1718     );
1719     if ( $bad_upgrade ) {
1720       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1721                   ' AND  history_date >  '.($bad_upgrade->_date - 3600);
1722       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1723         qsearch({
1724           'select'    => '*',
1725           'table'     => 'h_part_pkg_option',
1726           'hashref'   => {},
1727           'extra_sql' => "$where AND history_action = 'delete'",
1728           'order_by'  => 'ORDER BY history_date ASC',
1729         });
1730       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1731         qsearch({
1732           'select'    => '*',
1733           'table'     => 'h_pkg_svc',
1734           'hashref'   => {},
1735           'extra_sql' => "$where AND history_action = 'replace_old'",
1736           'order_by'  => 'ORDER BY history_date ASC',
1737         });
1738       my %opt;
1739       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1740         my $pkgpart ||= $deleted->pkgpart;
1741         $opt{$pkgpart} ||= {
1742           options => {},
1743           pkg_svc => {},
1744           primary_svc => '',
1745           hidden_svc => {},
1746         };
1747         if ( $deleted->isa('FS::part_pkg_option') ) {
1748           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1749         } else { # pkg_svc
1750           my $svcpart = $deleted->svcpart;
1751           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1752           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1753           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1754         }
1755       }
1756       foreach my $pkgpart (keys %opt) {
1757         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1758         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1759         if ( $error ) {
1760           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1761         }
1762       }
1763     } # $bad_upgrade exists
1764     else { # do the original upgrade, but correctly this time
1765       @part_pkg = qsearch('part_pkg', {
1766           fcc_ds0s        => { op => '>', value => 0 },
1767           fcc_voip_class  => ''
1768       });
1769       foreach my $part_pkg (@part_pkg) {
1770         $part_pkg->set(fcc_voip_class => 2);
1771         my @pkg_svc = $part_pkg->pkg_svc;
1772         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1773         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1774         my $error = $part_pkg->replace(
1775           $part_pkg->replace_old,
1776           options     => { $part_pkg->options },
1777           pkg_svc     => \%quantity,
1778           hidden_svc  => \%hidden,
1779           primary_svc => ($part_pkg->svcpart || ''),
1780         );
1781         die $error if $error;
1782       }
1783     }
1784     FS::upgrade_journal->set_done($upgrade);
1785   }
1786
1787 }
1788
1789 =item curuser_pkgs_sql
1790
1791 Returns an SQL fragment for searching for packages the current user can
1792 use, either via part_pkg.agentnum directly, or via agent type (see
1793 L<FS::type_pkgs>).
1794
1795 =cut
1796
1797 sub curuser_pkgs_sql {
1798   my $class = shift;
1799
1800   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1801
1802 }
1803
1804 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1805
1806 Returns an SQL fragment for searching for packages the provided agent or agents
1807 can use, either via part_pkg.agentnum directly, or via agent type (see
1808 L<FS::type_pkgs>).
1809
1810 =cut
1811
1812 sub agent_pkgs_sql {
1813   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1814   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1815
1816   $class->_pkgs_sql(@agentnums); #is this why
1817
1818 }
1819
1820 sub _pkgs_sql {
1821   my( $class, @agentnums ) = @_;
1822   my $agentnums = join(',', @agentnums);
1823
1824   "
1825     (
1826       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1827       OR ( agentnum IS NULL
1828            AND EXISTS ( SELECT 1
1829                           FROM type_pkgs
1830                             LEFT JOIN agent_type USING ( typenum )
1831                             LEFT JOIN agent AS typeagent USING ( typenum )
1832                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1833                             AND typeagent.agentnum IN ($agentnums)
1834                       )
1835          )
1836     )
1837   ";
1838
1839 }
1840
1841 =back
1842
1843 =head1 SUBROUTINES
1844
1845 =over 4
1846
1847 =item plan_info
1848
1849 =cut
1850
1851 #false laziness w/part_export & cdr
1852 my %info;
1853 foreach my $INC ( @INC ) {
1854   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1855   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1856     warn "attempting to load plan info from $file\n" if $DEBUG;
1857     $file =~ /\/(\w+)\.pm$/ or do {
1858       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1859       next;
1860     };
1861     my $mod = $1;
1862     my $info = eval "use FS::part_pkg::$mod; ".
1863                     "\\%FS::part_pkg::$mod\::info;";
1864     if ( $@ ) {
1865       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1866       next;
1867     }
1868     unless ( keys %$info ) {
1869       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1870       next;
1871     }
1872     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1873     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1874     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1875     #  next;
1876     #}
1877     $info{$mod} = $info;
1878     $info->{'weight'} ||= 0; # quiet warnings
1879   }
1880 }
1881
1882 # copy one level deep to allow replacement of fields and fieldorder
1883 tie %plans, 'Tie::IxHash',
1884   map  { my %infohash = %{ $info{$_} }; 
1885           $_ => \%infohash }
1886   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1887   keys %info;
1888
1889 # inheritance of plan options
1890 foreach my $name (keys(%info)) {
1891   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1892     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1893     delete $plans{$name};
1894     next;
1895   }
1896   my $parents = $info{$name}->{'inherit_fields'} || [];
1897   my (%fields, %field_exists, @fieldorder);
1898   foreach my $parent ($name, @$parents) {
1899     if ( !exists($info{$parent}) ) {
1900       warn "$name tried to inherit from nonexistent '$parent'\n";
1901       next;
1902     }
1903     %fields = ( # avoid replacing existing fields
1904       %{ $info{$parent}->{'fields'} || {} },
1905       %fields
1906     );
1907     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
1908       # avoid duplicates
1909       next if $field_exists{$_};
1910       $field_exists{$_} = 1;
1911       # allow inheritors to remove inherited fields from the fieldorder
1912       push @fieldorder, $_ if !exists($fields{$_}) or
1913                               !exists($fields{$_}->{'disabled'});
1914     }
1915   }
1916   $plans{$name}->{'fields'} = \%fields;
1917   $plans{$name}->{'fieldorder'} = \@fieldorder;
1918 }
1919
1920 sub plan_info {
1921   \%plans;
1922 }
1923
1924
1925 =back
1926
1927 =head1 NEW PLAN CLASSES
1928
1929 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1930 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1931 other modules in FS/FS/part_pkg/ as a guide.
1932
1933 =head1 BUGS
1934
1935 The delete method is unimplemented.
1936
1937 setup and recur semantics are not yet defined (and are implemented in
1938 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1939
1940 plandata should go
1941
1942 part_pkg_taxrate is Pg specific
1943
1944 replace should be smarter about managing the related tables (options, pkg_svc)
1945
1946 =head1 SEE ALSO
1947
1948 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1949 schema.html from the base documentation.
1950
1951 =cut
1952
1953 1;
1954