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