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