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