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