c9b24110346761a67cda7fa6b0921f6b365b1d45
[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 format OPTION DATA
1322
1323 Returns data formatted according to the function 'format' described
1324 in the plan info.  Returns DATA if no such function exists.
1325
1326 =cut
1327
1328 sub format {
1329   my ($self, $option, $data) = (shift, shift, shift);
1330   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1331     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1332   }else{
1333     $data;
1334   }
1335 }
1336
1337 =item parse OPTION DATA
1338
1339 Returns data parsed according to the function 'parse' described
1340 in the plan info.  Returns DATA if no such function exists.
1341
1342 =cut
1343
1344 sub parse {
1345   my ($self, $option, $data) = (shift, shift, shift);
1346   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1347     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1348   }else{
1349     $data;
1350   }
1351 }
1352
1353 =back
1354
1355 =cut
1356
1357 =head1 CLASS METHODS
1358
1359 =over 4
1360
1361 =cut
1362
1363 # _upgrade_data
1364 #
1365 # Used by FS::Upgrade to migrate to a new database.
1366
1367 sub _upgrade_data { # class method
1368   my($class, %opts) = @_;
1369
1370   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1371
1372   my @part_pkg = qsearch({
1373     'table'     => 'part_pkg',
1374     'extra_sql' => "WHERE ". join(' OR ',
1375                      ( map "($_ IS NOT NULL AND $_ != '' )",
1376                            qw( plandata setup recur ) ),
1377                      'plan IS NULL', "plan = '' ",
1378                    ),
1379   });
1380
1381   foreach my $part_pkg (@part_pkg) {
1382
1383     unless ( $part_pkg->plan ) {
1384       $part_pkg->plan('flat');
1385     }
1386
1387     if ( length($part_pkg->option('setup_fee')) == 0 
1388          && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) {
1389
1390       my $opt = new FS::part_pkg_option {
1391         'pkgpart'     => $part_pkg->pkgpart,
1392         'optionname'  => 'setup_fee',
1393         'optionvalue' => $1,
1394       };
1395       my $error = $opt->insert;
1396       die $error if $error;
1397
1398
1399       #} else {
1400       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1401       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1402     }
1403     $part_pkg->setup('');
1404
1405     if ( length($part_pkg->option('recur_fee')) == 0
1406          && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) {
1407
1408         my $opt = new FS::part_pkg_option {
1409           'pkgpart'     => $part_pkg->pkgpart,
1410           'optionname'  => 'recur_fee',
1411           'optionvalue' => $1,
1412         };
1413         my $error = $opt->insert;
1414         die $error if $error;
1415
1416
1417       #} else {
1418       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1419       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1420     }
1421     $part_pkg->recur('');
1422
1423     $part_pkg->replace; #this should take care of plandata, right?
1424
1425   }
1426
1427   # now upgrade to the explicit custom flag
1428
1429   @part_pkg = qsearch({
1430     'table'     => 'part_pkg',
1431     'hashref'   => { disabled => 'Y', custom => '' },
1432     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1433   });
1434
1435   foreach my $part_pkg (@part_pkg) {
1436     my $new = new FS::part_pkg { $part_pkg->hash };
1437     $new->custom('Y');
1438     my $comment = $part_pkg->comment;
1439     $comment =~ s/^\(CUSTOM\) //;
1440     $comment = '(none)' unless $comment =~ /\S/;
1441     $new->comment($comment);
1442
1443     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1444     my $primary = $part_pkg->svcpart;
1445     my $options = { $part_pkg->options };
1446
1447     my $error = $new->replace( $part_pkg,
1448                                'pkg_svc'     => $pkg_svc,
1449                                'primary_svc' => $primary,
1450                                'options'     => $options,
1451                              );
1452     die $error if $error;
1453   }
1454
1455   my @part_pkg_option = qsearch('part_pkg_option',
1456     { 'optionname'  => 'unused_credit',
1457       'optionvalue' => 1,
1458     });
1459   foreach my $old_opt (@part_pkg_option) {
1460     my $pkgpart = $old_opt->pkgpart;
1461     my $error = $old_opt->delete;
1462     die $error if $error;
1463
1464     foreach (qw(unused_credit_cancel unused_credit_change)) {
1465       my $new_opt = new FS::part_pkg_option {
1466         'pkgpart'     => $pkgpart,
1467         'optionname'  => $_,
1468         'optionvalue' => 1,
1469       };
1470       $error = $new_opt->insert;
1471       die $error if $error;
1472     }
1473   }
1474
1475   # migrate use_disposition_taqua and use_disposition to disposition_in
1476   @part_pkg_option = qsearch('part_pkg_option',
1477     { 'optionname'  => { op => 'LIKE',
1478                          value => 'use_disposition%',
1479                        },
1480       'optionvalue' => 1,
1481     });
1482   my %newopts = map { $_->pkgpart => $_ } 
1483     qsearch('part_pkg_option',  { 'optionname'  => 'disposition_in', } );
1484   foreach my $old_opt (@part_pkg_option) {
1485         my $pkgpart = $old_opt->pkgpart;
1486         my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' 
1487                                                                   : 'ANSWERED';
1488         my $error = $old_opt->delete;
1489         die $error if $error;
1490
1491         if ( exists($newopts{$pkgpart}) ) {
1492             my $opt = $newopts{$pkgpart};
1493             $opt->optionvalue($opt->optionvalue.",$newval");
1494             $error = $opt->replace;
1495             die $error if $error;
1496         } else {
1497             my $new_opt = new FS::part_pkg_option {
1498                 'pkgpart'     => $pkgpart,
1499                 'optionname'  => 'disposition_in',
1500                 'optionvalue' => $newval,
1501               };
1502               $error = $new_opt->insert;
1503               die $error if $error;
1504               $newopts{$pkgpart} = $new_opt;
1505         }
1506   }
1507
1508   # set any package with FCC voice lines to the "VoIP with broadband" category
1509   # for backward compatibility
1510   #
1511   # recover from a bad upgrade bug
1512   my $upgrade = 'part_pkg_fcc_voip_class_FIX';
1513   if (!FS::upgrade_journal->is_done($upgrade)) {
1514     my $bad_upgrade = qsearchs('upgrade_journal', 
1515       { upgrade => 'part_pkg_fcc_voip_class' }
1516     );
1517     if ( $bad_upgrade ) {
1518       my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
1519                   ' AND  history_date >  '.($bad_upgrade->_date - 600);
1520       my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
1521         qsearch({
1522           'select'    => '*',
1523           'table'     => 'h_part_pkg_option',
1524           'hashref'   => {},
1525           'extra_sql' => "$where AND history_action = 'delete'",
1526           'order_by'  => 'ORDER BY history_date ASC',
1527         });
1528       my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
1529         qsearch({
1530           'select'    => '*',
1531           'table'     => 'h_pkg_svc',
1532           'hashref'   => {},
1533           'extra_sql' => "$where AND history_action = 'replace_old'",
1534           'order_by'  => 'ORDER BY history_date ASC',
1535         });
1536       my %opt;
1537       foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
1538         my $pkgpart ||= $deleted->pkgpart;
1539         $opt{$pkgpart} ||= {
1540           options => {},
1541           pkg_svc => {},
1542           primary_svc => '',
1543           hidden_svc => {},
1544         };
1545         if ( $deleted->isa('FS::part_pkg_option') ) {
1546           $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
1547         } else { # pkg_svc
1548           my $svcpart = $deleted->svcpart;
1549           $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
1550           $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
1551           $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
1552         }
1553       }
1554       foreach my $pkgpart (keys %opt) {
1555         my $part_pkg = FS::part_pkg->by_key($pkgpart);
1556         my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
1557         if ( $error ) {
1558           die "error recovering damaged pkgpart $pkgpart:\n$error\n";
1559         }
1560       }
1561     } # $bad_upgrade exists
1562     else { # do the original upgrade, but correctly this time
1563       @part_pkg = qsearch('part_pkg', {
1564           fcc_ds0s        => { op => '>', value => 0 },
1565           fcc_voip_class  => ''
1566       });
1567       foreach my $part_pkg (@part_pkg) {
1568         $part_pkg->set(fcc_voip_class => 2);
1569         my @pkg_svc = $part_pkg->pkg_svc;
1570         my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
1571         my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
1572         my $error = $part_pkg->replace(
1573           $part_pkg->replace_old,
1574           options     => { $part_pkg->options },
1575           pkg_svc     => \%quantity,
1576           hidden_svc  => \%hidden,
1577           primary_svc => ($part_pkg->svcpart || ''),
1578         );
1579         die $error if $error;
1580       }
1581     }
1582     FS::upgrade_journal->set_done($upgrade);
1583   }
1584
1585 }
1586
1587 =item curuser_pkgs_sql
1588
1589 Returns an SQL fragment for searching for packages the current user can
1590 use, either via part_pkg.agentnum directly, or via agent type (see
1591 L<FS::type_pkgs>).
1592
1593 =cut
1594
1595 sub curuser_pkgs_sql {
1596   my $class = shift;
1597
1598   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1599
1600 }
1601
1602 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1603
1604 Returns an SQL fragment for searching for packages the provided agent or agents
1605 can use, either via part_pkg.agentnum directly, or via agent type (see
1606 L<FS::type_pkgs>).
1607
1608 =cut
1609
1610 sub agent_pkgs_sql {
1611   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1612   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1613
1614   $class->_pkgs_sql(@agentnums); #is this why
1615
1616 }
1617
1618 sub _pkgs_sql {
1619   my( $class, @agentnums ) = @_;
1620   my $agentnums = join(',', @agentnums);
1621
1622   "
1623     (
1624       ( agentnum IS NOT NULL AND agentnum IN ($agentnums) )
1625       OR ( agentnum IS NULL
1626            AND EXISTS ( SELECT 1
1627                           FROM type_pkgs
1628                             LEFT JOIN agent_type USING ( typenum )
1629                             LEFT JOIN agent AS typeagent USING ( typenum )
1630                           WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1631                             AND typeagent.agentnum IN ($agentnums)
1632                       )
1633          )
1634     )
1635   ";
1636
1637 }
1638
1639 =back
1640
1641 =head1 SUBROUTINES
1642
1643 =over 4
1644
1645 =item plan_info
1646
1647 =cut
1648
1649 #false laziness w/part_export & cdr
1650 my %info;
1651 foreach my $INC ( @INC ) {
1652   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1653   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1654     warn "attempting to load plan info from $file\n" if $DEBUG;
1655     $file =~ /\/(\w+)\.pm$/ or do {
1656       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1657       next;
1658     };
1659     my $mod = $1;
1660     my $info = eval "use FS::part_pkg::$mod; ".
1661                     "\\%FS::part_pkg::$mod\::info;";
1662     if ( $@ ) {
1663       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1664       next;
1665     }
1666     unless ( keys %$info ) {
1667       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1668       next;
1669     }
1670     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1671     #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1672     #  warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1673     #  next;
1674     #}
1675     $info{$mod} = $info;
1676     $info->{'weight'} ||= 0; # quiet warnings
1677   }
1678 }
1679
1680 # copy one level deep to allow replacement of fields and fieldorder
1681 tie %plans, 'Tie::IxHash',
1682   map  { my %infohash = %{ $info{$_} }; 
1683           $_ => \%infohash }
1684   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1685   keys %info;
1686
1687 # inheritance of plan options
1688 foreach my $name (keys(%info)) {
1689   if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) {
1690     warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG;
1691     delete $plans{$name};
1692     next;
1693   }
1694   my $parents = $info{$name}->{'inherit_fields'} || [];
1695   my (%fields, %field_exists, @fieldorder);
1696   foreach my $parent ($name, @$parents) {
1697     if ( !exists($info{$parent}) ) {
1698       warn "$name tried to inherit from nonexistent '$parent'\n";
1699       next;
1700     }
1701     %fields = ( # avoid replacing existing fields
1702       %{ $info{$parent}->{'fields'} || {} },
1703       %fields
1704     );
1705     foreach (@{ $info{$parent}->{'fieldorder'} || [] }) {
1706       # avoid duplicates
1707       next if $field_exists{$_};
1708       $field_exists{$_} = 1;
1709       # allow inheritors to remove inherited fields from the fieldorder
1710       push @fieldorder, $_ if !exists($fields{$_}) or
1711                               !exists($fields{$_}->{'disabled'});
1712     }
1713   }
1714   $plans{$name}->{'fields'} = \%fields;
1715   $plans{$name}->{'fieldorder'} = \@fieldorder;
1716 }
1717
1718 sub plan_info {
1719   \%plans;
1720 }
1721
1722
1723 =back
1724
1725 =head1 NEW PLAN CLASSES
1726
1727 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1728 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1729 other modules in FS/FS/part_pkg/ as a guide.
1730
1731 =head1 BUGS
1732
1733 The delete method is unimplemented.
1734
1735 setup and recur semantics are not yet defined (and are implemented in
1736 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1737
1738 plandata should go
1739
1740 part_pkg_taxrate is Pg specific
1741
1742 replace should be smarter about managing the related tables (options, pkg_svc)
1743
1744 =head1 SEE ALSO
1745
1746 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1747 schema.html from the base documentation.
1748
1749 =cut
1750
1751 1;
1752