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