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