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