eliminate harmless upgrade error:
[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 );
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_taxoverride;
20 use FS::part_pkg_taxproduct;
21 use FS::part_pkg_link;
22
23 @ISA = qw( FS::m2m_Common FS::option_Common );
24 $DEBUG = 0;
25 $setup_hack = 0;
26
27 =head1 NAME
28
29 FS::part_pkg - Object methods for part_pkg objects
30
31 =head1 SYNOPSIS
32
33   use FS::part_pkg;
34
35   $record = new FS::part_pkg \%hash
36   $record = new FS::part_pkg { 'column' => 'value' };
37
38   $custom_record = $template_record->clone;
39
40   $error = $record->insert;
41
42   $error = $new_record->replace($old_record);
43
44   $error = $record->delete;
45
46   $error = $record->check;
47
48   @pkg_svc = $record->pkg_svc;
49
50   $svcnum = $record->svcpart;
51   $svcnum = $record->svcpart( 'svc_acct' );
52
53 =head1 DESCRIPTION
54
55 An FS::part_pkg object represents a package definition.  FS::part_pkg
56 inherits from FS::Record.  The following fields are currently supported:
57
58 =over 4
59
60 =item pkgpart - primary key (assigned automatically for new package definitions)
61
62 =item pkg - Text name of this package definition (customer-viewable)
63
64 =item comment - Text name of this package definition (non-customer-viewable)
65
66 =item classnum - Optional package class (see L<FS::pkg_class>)
67
68 =item promo_code - Promotional code
69
70 =item setup - Setup fee expression (deprecated)
71
72 =item freq - Frequency of recurring fee
73
74 =item recur - Recurring fee expression (deprecated)
75
76 =item setuptax - Setup fee tax exempt flag, empty or `Y'
77
78 =item recurtax - Recurring fee tax exempt flag, empty or `Y'
79
80 =item taxclass - Tax class 
81
82 =item plan - Price plan
83
84 =item plandata - Price plan data (deprecated - see L<FS::part_pkg_option> instead)
85
86 =item disabled - Disabled flag, empty or `Y'
87
88 =item custom - Custom flag, empty or `Y'
89
90 =item setup_cost - for cost tracking
91
92 =item recur_cost - for cost tracking
93
94 =item pay_weight - Weight (relative to credit_weight and other package definitions) that controls payment application to specific line items.
95
96 =item credit_weight - Weight (relative to other package definitions) that controls credit application to specific line items.
97
98 =item agentnum - Optional agentnum (see L<FS::agent>)
99
100 =back
101
102 =head1 METHODS
103
104 =over 4 
105
106 =item new HASHREF
107
108 Creates a new package definition.  To add the package definition to
109 the database, see L<"insert">.
110
111 =cut
112
113 sub table { 'part_pkg'; }
114
115 =item clone
116
117 An alternate constructor.  Creates a new package definition by duplicating
118 an existing definition.  A new pkgpart is assigned and the custom flag is
119 set to Y.  To add the package definition to the database, see L<"insert">.
120
121 =cut
122
123 sub clone {
124   my $self = shift;
125   my $class = ref($self);
126   my %hash = $self->hash;
127   $hash{'pkgpart'} = '';
128   $hash{'custom'} = 'Y';
129   #new FS::part_pkg ( \%hash ); # ?
130   new $class ( \%hash ); # ?
131 }
132
133 =item insert [ , OPTION => VALUE ... ]
134
135 Adds this package definition to the database.  If there is an error,
136 returns the error, otherwise returns false.
137
138 Currently available options are: I<pkg_svc>, I<primary_svc>, I<cust_pkg>, 
139 I<custnum_ref> and I<options>.
140
141 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
142 values, appropriate FS::pkg_svc records will be inserted.
143
144 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
145 FS::pkg_svc record will be updated.
146
147 If I<cust_pkg> is set to a pkgnum of a FS::cust_pkg record (or the FS::cust_pkg
148 record itself), the object will be updated to point to this package definition.
149
150 In conjunction with I<cust_pkg>, if I<custnum_ref> is set to a scalar reference,
151 the scalar will be updated with the custnum value from the cust_pkg record.
152
153 If I<tax_overrides> is set to a hashref with usage classes as keys and comma
154 separated tax class numbers as values, appropriate FS::part_pkg_taxoverride
155 records will be inserted.
156
157 If I<options> is set to a hashref of options, appropriate FS::part_pkg_option
158 records will be inserted.
159
160 =cut
161
162 sub insert {
163   my $self = shift;
164   my %options = @_;
165   warn "FS::part_pkg::insert called on $self with options ".
166        join(', ', map "$_=>$options{$_}", keys %options)
167     if $DEBUG;
168
169   local $SIG{HUP} = 'IGNORE';
170   local $SIG{INT} = 'IGNORE';
171   local $SIG{QUIT} = 'IGNORE';
172   local $SIG{TERM} = 'IGNORE';
173   local $SIG{TSTP} = 'IGNORE';
174   local $SIG{PIPE} = 'IGNORE';
175
176   my $oldAutoCommit = $FS::UID::AutoCommit;
177   local $FS::UID::AutoCommit = 0;
178   my $dbh = dbh;
179
180   warn "  inserting part_pkg record" if $DEBUG;
181   my $error = $self->SUPER::insert( $options{options} );
182   if ( $error ) {
183     $dbh->rollback if $oldAutoCommit;
184     return $error;
185   }
186
187   my $conf = new FS::Conf;
188   if ( $conf->exists('agent_defaultpkg') ) {
189     warn "  agent_defaultpkg set; allowing all agents to purchase package"
190       if $DEBUG;
191     foreach my $agent_type ( qsearch('agent_type', {} ) ) {
192       my $type_pkgs = new FS::type_pkgs({
193         'typenum' => $agent_type->typenum,
194         'pkgpart' => $self->pkgpart,
195       });
196       my $error = $type_pkgs->insert;
197       if ( $error ) {
198         $dbh->rollback if $oldAutoCommit;
199         return $error;
200       }
201     }
202   }
203
204   warn "  inserting part_pkg_taxoverride records" if $DEBUG;
205   my %overrides = %{ $options{'tax_overrides'} || {} };
206   foreach my $usage_class ( keys %overrides ) {
207     my @overrides = (grep "$_", split (',', $overrides{$usage_class}) );
208     my $error = $self->process_m2m (
209                   'link_table'   => 'part_pkg_taxoverride',
210                   'target_table' => 'tax_class',
211                   'hashref'      => { 'usage_class' => $usage_class },
212                   'params'       => \@overrides,
213                 );
214     if ( $error ) {
215       $dbh->rollback if $oldAutoCommit;
216       return $error;
217     }
218   }
219
220   warn "  inserting pkg_svc records" if $DEBUG;
221   my $pkg_svc = $options{'pkg_svc'} || {};
222   foreach my $part_svc ( qsearch('part_svc', {} ) ) {
223     my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
224     my $primary_svc =
225       ( $options{'primary_svc'} && $options{'primary_svc'}==$part_svc->svcpart )
226         ? 'Y'
227         : '';
228
229     my $pkg_svc = new FS::pkg_svc( {
230       'pkgpart'     => $self->pkgpart,
231       'svcpart'     => $part_svc->svcpart,
232       'quantity'    => $quantity, 
233       'primary_svc' => $primary_svc,
234     } );
235     my $error = $pkg_svc->insert;
236     if ( $error ) {
237       $dbh->rollback if $oldAutoCommit;
238       return $error;
239     }
240   }
241
242   if ( $options{'cust_pkg'} ) {
243     warn "  updating cust_pkg record " if $DEBUG;
244     my $old_cust_pkg =
245       ref($options{'cust_pkg'})
246         ? $options{'cust_pkg'}
247         : qsearchs('cust_pkg', { pkgnum => $options{'cust_pkg'} } );
248     ${ $options{'custnum_ref'} } = $old_cust_pkg->custnum
249       if $options{'custnum_ref'};
250     my %hash = $old_cust_pkg->hash;
251     $hash{'pkgpart'} = $self->pkgpart,
252     my $new_cust_pkg = new FS::cust_pkg \%hash;
253     local($FS::cust_pkg::disable_agentcheck) = 1;
254     my $error = $new_cust_pkg->replace($old_cust_pkg);
255     if ( $error ) {
256       $dbh->rollback if $oldAutoCommit;
257       return "Error modifying cust_pkg record: $error";
258     }
259   }
260
261   warn "  commiting transaction" if $DEBUG;
262   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
263
264   '';
265 }
266
267 =item delete
268
269 Currently unimplemented.
270
271 =cut
272
273 sub delete {
274   return "Can't (yet?) delete package definitions.";
275 # check & make sure the pkgpart isn't in cust_pkg or type_pkgs?
276 }
277
278 =item replace OLD_RECORD [ , OPTION => VALUE ... ]
279
280 Replaces OLD_RECORD with this one in the database.  If there is an error,
281 returns the error, otherwise returns false.
282
283 Currently available options are: I<pkg_svc>, I<primary_svc> and I<options>
284
285 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
286 values, the appropriate FS::pkg_svc records will be replaced.
287
288 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
289 FS::pkg_svc record will be updated.
290
291 If I<options> is set to a hashref, the appropriate FS::part_pkg_option records
292 will be replaced.
293
294 =cut
295
296 sub replace {
297   my $new = shift;
298
299   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
300               ? shift
301               : $new->replace_old;
302
303   my $options = 
304     ( ref($_[0]) eq 'HASH' )
305       ? shift
306       : { @_ };
307
308   $options->{options} = {} unless defined($options->{options});
309
310   warn "FS::part_pkg::replace called on $new to replace $old with options".
311        join(', ', map "$_ => ". $options->{$_}, keys %$options)
312     if $DEBUG;
313
314   local $SIG{HUP} = 'IGNORE';
315   local $SIG{INT} = 'IGNORE';
316   local $SIG{QUIT} = 'IGNORE';
317   local $SIG{TERM} = 'IGNORE';
318   local $SIG{TSTP} = 'IGNORE';
319   local $SIG{PIPE} = 'IGNORE';
320
321   my $oldAutoCommit = $FS::UID::AutoCommit;
322   local $FS::UID::AutoCommit = 0;
323   my $dbh = dbh;
324
325   #plandata shit stays in replace for upgrades until after 2.0 (or edit
326   #_upgrade_data)
327   warn "  saving legacy plandata" if $DEBUG;
328   my $plandata = $new->get('plandata');
329   $new->set('plandata', '');
330
331   warn "  deleting old part_pkg_option records" if $DEBUG;
332   foreach my $part_pkg_option ( $old->part_pkg_option ) {
333     my $error = $part_pkg_option->delete;
334     if ( $error ) {
335       $dbh->rollback if $oldAutoCommit;
336       return $error;
337     }
338   }
339
340   warn "  replacing part_pkg record" if $DEBUG;
341   my $error = $new->SUPER::replace($old, $options->{options} );
342   if ( $error ) {
343     $dbh->rollback if $oldAutoCommit;
344     return $error;
345   }
346
347   warn "  inserting part_pkg_option records for plandata: $plandata|" if $DEBUG;
348   foreach my $part_pkg_option ( 
349     map { /^(\w+)=(.*)$/ or do { $dbh->rollback if $oldAutoCommit;
350                                  return "illegal plandata: $plandata";
351                                };
352           new FS::part_pkg_option {
353             'pkgpart'     => $new->pkgpart,
354             'optionname'  => $1,
355             'optionvalue' => $2,
356           };
357         }
358     split("\n", $plandata)
359   ) {
360     my $error = $part_pkg_option->insert;
361     if ( $error ) {
362       $dbh->rollback if $oldAutoCommit;
363       return $error;
364     }
365   }
366
367   warn "  replacing pkg_svc records" if $DEBUG;
368   my $pkg_svc = $options->{'pkg_svc'} || {};
369   foreach my $part_svc ( qsearch('part_svc', {} ) ) {
370     my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
371     my $primary_svc =
372       ( defined($options->{'primary_svc'}) && $options->{'primary_svc'}
373         && $options->{'primary_svc'} == $part_svc->svcpart
374       )
375         ? 'Y'
376         : '';
377
378
379     my $old_pkg_svc = qsearchs('pkg_svc', {
380       'pkgpart' => $old->pkgpart,
381       'svcpart' => $part_svc->svcpart,
382     } );
383     my $old_quantity = $old_pkg_svc ? $old_pkg_svc->quantity : 0;
384     my $old_primary_svc =
385       ( $old_pkg_svc && $old_pkg_svc->dbdef_table->column('primary_svc') )
386         ? $old_pkg_svc->primary_svc
387         : '';
388     next unless $old_quantity != $quantity || $old_primary_svc ne $primary_svc;
389   
390     my $new_pkg_svc = new FS::pkg_svc( {
391       'pkgsvcnum'   => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
392       'pkgpart'     => $new->pkgpart,
393       'svcpart'     => $part_svc->svcpart,
394       'quantity'    => $quantity, 
395       'primary_svc' => $primary_svc,
396     } );
397     my $error = $old_pkg_svc
398                   ? $new_pkg_svc->replace($old_pkg_svc)
399                   : $new_pkg_svc->insert;
400     if ( $error ) {
401       $dbh->rollback if $oldAutoCommit;
402       return $error;
403     }
404   }
405
406   warn "  commiting transaction" if $DEBUG;
407   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
408   '';
409 }
410
411 =item check
412
413 Checks all fields to make sure this is a valid package definition.  If
414 there is an error, returns the error, otherwise returns false.  Called by the
415 insert and replace methods.
416
417 =cut
418
419 sub check {
420   my $self = shift;
421   warn "FS::part_pkg::check called on $self" if $DEBUG;
422
423   for (qw(setup recur plandata)) {
424     #$self->set($_=>0) if $self->get($_) =~ /^\s*$/; }
425     return "Use of $_ field is deprecated; set a plan and options: ".
426            $self->get($_)
427       if length($self->get($_));
428     $self->set($_, '');
429   }
430
431   if ( $self->dbdef_table->column('freq')->type =~ /(int)/i ) {
432     my $error = $self->ut_number('freq');
433     return $error if $error;
434   } else {
435     $self->freq =~ /^(\d+[hdw]?)$/
436       or return "Illegal or empty freq: ". $self->freq;
437     $self->freq($1);
438   }
439
440   my @null_agentnum_right = ( 'Edit global package definitions' );
441   push @null_agentnum_right, 'One-time charge'
442     if $self->freq =~ /^0/;
443   push @null_agentnum_right, 'Customize customer package'
444     if $self->disabled eq 'Y'; #good enough
445
446   my $error = $self->ut_numbern('pkgpart')
447     || $self->ut_text('pkg')
448     || $self->ut_text('comment')
449     || $self->ut_textn('promo_code')
450     || $self->ut_alphan('plan')
451     || $self->ut_enum('setuptax', [ '', 'Y' ] )
452     || $self->ut_enum('recurtax', [ '', 'Y' ] )
453     || $self->ut_textn('taxclass')
454     || $self->ut_enum('disabled', [ '', 'Y' ] )
455     || $self->ut_enum('custom', [ '', 'Y' ] )
456     #|| $self->ut_moneyn('setup_cost')
457     #|| $self->ut_moneyn('recur_cost')
458     || $self->ut_floatn('setup_cost')
459     || $self->ut_floatn('recur_cost')
460     || $self->ut_floatn('pay_weight')
461     || $self->ut_floatn('credit_weight')
462     || $self->ut_numbern('taxproductnum')
463     || $self->ut_foreign_keyn('taxproductnum',
464                               'part_pkg_taxproduct',
465                               'taxproductnum'
466                              )
467     || ( $setup_hack
468            ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' )
469            : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right)
470        )
471     || $self->SUPER::check
472   ;
473   return $error if $error;
474
475   if ( $self->classnum !~ /^$/ ) {
476     my $error = $self->ut_foreign_key('classnum', 'pkg_class', 'classnum');
477     return $error if $error;
478   } else {
479     $self->classnum('');
480   }
481
482   return 'Unknown plan '. $self->plan
483     unless exists($plans{$self->plan});
484
485   my $conf = new FS::Conf;
486   return 'Taxclass is required'
487     if ! $self->taxclass && $conf->exists('require_taxclasses');
488
489   '';
490 }
491
492 =item pkg_comment [ OPTION => VALUE... ]
493
494 Returns an (internal) string representing this package.  Currently,
495 "pkgpart: pkg - comment", is returned.  "pkg - comment" may be returned in the
496 future, omitting pkgpart.  The comment will have '(CUSTOM) ' prepended if
497 custom is Y.
498
499 If the option nopkgpart is true then the "pkgpart: ' is omitted.
500
501 =cut
502
503 sub pkg_comment {
504   my $self = shift;
505   my %opt = @_;
506
507   #$self->pkg. ' - '. $self->comment;
508   #$self->pkg. ' ('. $self->comment. ')';
509   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
510   $pre. $self->pkg. ' - '. $self->custom_comment;
511 }
512
513 sub custom_comment {
514   my $self = shift;
515   ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment;
516 }
517
518 =item pkg_class
519
520 Returns the package class, as an FS::pkg_class object, or the empty string
521 if there is no package class.
522
523 =cut
524
525 sub pkg_class {
526   my $self = shift;
527   if ( $self->classnum ) {
528     qsearchs('pkg_class', { 'classnum' => $self->classnum } );
529   } else {
530     return '';
531   }
532 }
533
534 =item categoryname 
535
536 Returns the package category name, or the empty string if there is no package
537 category.
538
539 =cut
540
541 sub categoryname {
542   my $self = shift;
543   my $pkg_class = $self->pkg_class;
544   $pkg_class
545     ? $pkg_class->categoryname
546     : '';
547 }
548
549 =item classname 
550
551 Returns the package class name, or the empty string if there is no package
552 class.
553
554 =cut
555
556 sub classname {
557   my $self = shift;
558   my $pkg_class = $self->pkg_class;
559   $pkg_class
560     ? $pkg_class->classname
561     : '';
562 }
563
564 =item agent 
565
566 Returns the associated agent for this event, if any, as an FS::agent object.
567
568 =cut
569
570 sub agent {
571   my $self = shift;
572   qsearchs('agent', { 'agentnum' => $self->agentnum } );
573 }
574
575 =item pkg_svc [ HASHREF | OPTION => VALUE ]
576
577 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
578 definition (with non-zero quantity).
579
580 One option is available, I<disable_linked>.  If set true it will return the
581 services for this package definition alone, omitting services from any add-on
582 packages.
583
584 =cut
585
586 =item type_pkgs
587
588 Returns all FS::type_pkgs objects (see L<FS::type_pkgs>) for this package
589 definition.
590
591 =cut
592
593 sub type_pkgs {
594   my $self = shift;
595   qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
596 }
597
598 sub pkg_svc {
599   my $self = shift;
600
601 #  #sort { $b->primary cmp $a->primary } 
602 #    grep { $_->quantity }
603 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
604
605   my $opt = ref($_[0]) ? $_[0] : { @_ };
606   my %pkg_svc = map  { $_->svcpart => $_ }
607                 grep { $_->quantity }
608                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
609
610   unless ( $opt->{disable_linked} ) {
611     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
612       my @pkg_svc = grep { $_->quantity }
613                     qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
614       foreach my $pkg_svc ( @pkg_svc ) {
615         if ( $pkg_svc{$pkg_svc->svcpart} ) {
616           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
617           $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity);
618         } else {
619           $pkg_svc{$pkg_svc->svcpart} = $pkg_svc;
620         }
621       }
622     }
623   }
624
625   values(%pkg_svc);
626
627 }
628
629 =item svcpart [ SVCDB ]
630
631 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
632 associated with this package definition (see L<FS::pkg_svc>).  Returns
633 false if there not a primary service definition or exactly one service
634 definition with quantity 1, or if SVCDB is specified and does not match the
635 svcdb of the service definition.  SVCDB can be specified as a scalar table
636 name, such as 'svc_acct', or as an arrayref of possible table names.
637
638 =cut
639
640 sub svcpart {
641   my $pkg_svc = shift->_primary_pkg_svc(@_);
642   $pkg_svc ? $pkg_svc->svcpart : '';
643 }
644
645 =item part_svc [ SVCDB ]
646
647 Like the B<svcpart> method, but returns the FS::part_svc object (see
648 L<FS::part_svc>).
649
650 =cut
651
652 sub part_svc {
653   my $pkg_svc = shift->_primary_pkg_svc(@_);
654   $pkg_svc ? $pkg_svc->part_svc : '';
655 }
656
657 sub _primary_pkg_svc {
658   my $self = shift;
659
660   my $svcdb = scalar(@_) ? shift : [];
661   $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ];
662   my %svcdb = map { $_=>1 } @$svcdb;
663
664   my @svcdb_pkg_svc =
665     grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } }
666          $self->pkg_svc;
667
668   my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc;
669   @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc
670     unless @pkg_svc;
671   return '' if scalar(@pkg_svc) != 1;
672   $pkg_svc[0];
673 }
674
675 =item svcpart_unique_svcdb SVCDB
676
677 Returns the svcpart of a service definition (see L<FS::part_svc>) matching
678 SVCDB associated with this package definition (see L<FS::pkg_svc>).  Returns
679 false if there not a primary service definition for SVCDB or there are multiple
680 service definitions for SVCDB.
681
682 =cut
683
684 sub svcpart_unique_svcdb {
685   my( $self, $svcdb ) = @_;
686   my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc;
687   return '' if scalar(@svcdb_pkg_svc) != 1;
688   $svcdb_pkg_svc[0]->svcpart;
689 }
690
691 =item payby
692
693 Returns a list of the acceptable payment types for this package.  Eventually
694 this should come out of a database table and be editable, but currently has the
695 following logic instead:
696
697 If the package is free, the single item B<BILL> is
698 returned, otherwise, the single item B<CARD> is returned.
699
700 (CHEK?  LEC?  Probably shouldn't accept those by default, prone to abuse)
701
702 =cut
703
704 sub payby {
705   my $self = shift;
706   if ( $self->is_free ) {
707     ( 'BILL' );
708   } else {
709     ( 'CARD' );
710   }
711 }
712
713 =item is_free
714
715 Returns true if this package is free.  
716
717 =cut
718
719 sub is_free {
720   my $self = shift;
721   unless ( $self->plan ) {
722     $self->setup =~ /^\s*0+(\.0*)?\s*$/
723       && $self->recur =~ /^\s*0+(\.0*)?\s*$/;
724   } elsif ( $self->can('is_free_options') ) {
725     not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ }
726          map { $self->option($_) } 
727              $self->is_free_options;
728   } else {
729     warn "FS::part_pkg::is_free: FS::part_pkg::". $self->plan. " subclass ".
730          "provides neither is_free_options nor is_free method; returning false";
731     0;
732   }
733 }
734
735
736 sub freqs_href {
737   #method, class method or sub? #my $self = shift;
738
739   tie my %freq, 'Tie::IxHash', 
740     '0'    => '(no recurring fee)',
741     '1h'   => 'hourly',
742     '1d'   => 'daily',
743     '2d'   => 'every two days',
744     '3d'   => 'every three days',
745     '1w'   => 'weekly',
746     '2w'   => 'biweekly (every 2 weeks)',
747     '1'    => 'monthly',
748     '45d'  => 'every 45 days',
749     '2'    => 'bimonthly (every 2 months)',
750     '3'    => 'quarterly (every 3 months)',
751     '4'    => 'every 4 months',
752     '137d' => 'every 4 1/2 months (137 days)',
753     '6'    => 'semiannually (every 6 months)',
754     '12'   => 'annually',
755     '13'   => 'every 13 months (annually +1 month)',
756     '24'   => 'biannually (every 2 years)',
757     '36'   => 'triannually (every 3 years)',
758     '48'   => '(every 4 years)',
759     '60'   => '(every 5 years)',
760     '120'  => '(every 10 years)',
761   ;
762
763   \%freq;
764
765 }
766
767 =item freq_pretty
768
769 Returns an english representation of the I<freq> field, such as "monthly",
770 "weekly", "semi-annually", etc.
771
772 =cut
773
774 sub freq_pretty {
775   my $self = shift;
776   my $freq = $self->freq;
777
778   #my $freqs_href = $self->freqs_href;
779   my $freqs_href = freqs_href();
780
781   if ( exists($freqs_href->{$freq}) ) {
782     $freqs_href->{$freq};
783   } else {
784     my $interval = 'month';
785     if ( $freq =~ /^(\d+)([hdw])$/ ) {
786       my %interval = ( 'h' => 'hour', 'd'=>'day', 'w'=>'week' );
787       $interval = $interval{$2};
788     }
789     if ( $1 == 1 ) {
790       "every $interval";
791     } else {
792       "every $freq ${interval}s";
793     }
794   }
795 }
796
797 =item add_freq TIMESTAMP
798
799 Adds the frequency of this package to the provided timestamp and returns
800 the resulting timestamp, or -1 if the frequency of this package could not be
801 parsed (shouldn't happen).
802
803 =cut
804
805 sub add_freq {
806   my( $self, $date ) = @_;
807   my $freq = $self->freq;
808
809   #change this bit to use Date::Manip? CAREFUL with timezones (see
810   # mailing list archive)
811   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
812
813   if ( $self->freq =~ /^\d+$/ ) {
814     $mon += $self->freq;
815     until ( $mon < 12 ) { $mon -= 12; $year++; }
816   } elsif ( $self->freq =~ /^(\d+)w$/ ) {
817     my $weeks = $1;
818     $mday += $weeks * 7;
819   } elsif ( $self->freq =~ /^(\d+)d$/ ) {
820     my $days = $1;
821     $mday += $days;
822   } elsif ( $self->freq =~ /^(\d+)h$/ ) {
823     my $hours = $1;
824     $hour += $hours;
825   } else {
826     return -1;
827   }
828
829   timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year);
830 }
831
832 =item plandata
833
834 For backwards compatibility, returns the plandata field as well as all options
835 from FS::part_pkg_option.
836
837 =cut
838
839 sub plandata {
840   my $self = shift;
841   carp "plandata is deprecated";
842   if ( @_ ) {
843     $self->SUPER::plandata(@_);
844   } else {
845     my $plandata = $self->get('plandata');
846     my %options = $self->options;
847     $plandata .= join('', map { "$_=$options{$_}\n" } keys %options );
848     $plandata;
849   }
850 }
851
852 =item part_pkg_option
853
854 Returns all options as FS::part_pkg_option objects (see
855 L<FS::part_pkg_option>).
856
857 =cut
858
859 sub part_pkg_option {
860   my $self = shift;
861   qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
862 }
863
864 =item options 
865
866 Returns a list of option names and values suitable for assigning to a hash.
867
868 =cut
869
870 sub options {
871   my $self = shift;
872   map { $_->optionname => $_->optionvalue } $self->part_pkg_option;
873 }
874
875 =item option OPTIONNAME
876
877 Returns the option value for the given name, or the empty string.
878
879 =cut
880
881 sub option {
882   my( $self, $opt, $ornull ) = @_;
883   my $part_pkg_option =
884     qsearchs('part_pkg_option', {
885       pkgpart    => $self->pkgpart,
886       optionname => $opt,
887   } );
888   return $part_pkg_option->optionvalue if $part_pkg_option;
889   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
890                      split("\n", $self->get('plandata') );
891   return $plandata{$opt} if exists $plandata{$opt};
892   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
893         "not found in options or plandata!\n"
894     unless $ornull;
895   '';
896 }
897
898 =item bill_part_pkg_link
899
900 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
901
902 =cut
903
904 sub bill_part_pkg_link {
905   shift->_part_pkg_link('bill', @_);
906 }
907
908 =item svc_part_pkg_link
909
910 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
911
912 =cut
913
914 sub svc_part_pkg_link {
915   shift->_part_pkg_link('svc', @_);
916 }
917
918 sub _part_pkg_link {
919   my( $self, $type ) = @_;
920   qsearch('part_pkg_link', { 'src_pkgpart' => $self->pkgpart,
921                              'link_type'   => $type,
922                            }
923          );
924 }
925
926 sub self_and_bill_linked {
927   shift->_self_and_linked('bill', @_);
928 }
929
930 sub _self_and_linked {
931   my( $self, $type ) = @_;
932
933   ( $self,
934     map { $_->dst_pkg->_self_and_linked($type) }
935         $self->_part_pkg_link($type)
936   );
937 }
938
939 =item part_pkg_taxoverride [ CLASS ]
940
941 Returns all associated FS::part_pkg_taxoverride objects (see
942 L<FS::part_pkg_taxoverride>).  Limits the returned set to those
943 of class CLASS if defined.  Class may be one of 'setup', 'recur',
944 the empty string (default), or a usage class number (see L<FS::usage_class>).
945 When a class is specified, the empty string class (default) is returned
946 if no more specific values exist.
947
948 =cut
949
950 sub part_pkg_taxoverride {
951   my $self = shift;
952   my $class = shift;
953
954   my $hashref = { 'pkgpart' => $self->pkgpart };
955   $hashref->{'usage_class'} = $class if defined($class);
956   my @overrides = qsearch('part_pkg_taxoverride', $hashref );
957
958   unless ( scalar(@overrides) || !defined($class) || !$class ){
959     $hashref->{'usage_class'} = '';
960     @overrides = qsearch('part_pkg_taxoverride', $hashref );
961   }
962
963   @overrides;
964 }
965
966 =item has_taxproduct
967
968 Returns true if this package has any taxproduct associated with it.  
969
970 =cut
971
972 sub has_taxproduct {
973   my $self = shift;
974
975   $self->taxproductnum ||
976   scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } 
977           keys %{ {$self->options} }
978   )
979
980 }
981
982
983 =item taxproduct [ CLASS ]
984
985 Returns the associated tax product for this package definition (see
986 L<FS::part_pkg_taxproduct>).  CLASS may be one of 'setup', 'recur' or
987 the usage classnum (see L<FS::usage_class>).  Returns the default
988 tax product for this record if the more specific CLASS value does
989 not exist.
990
991 =cut
992
993 sub taxproduct {
994   my $self = shift;
995   my $class = shift;
996
997   my $part_pkg_taxproduct;
998
999   my $taxproductnum = $self->taxproductnum;
1000   if ($class) { 
1001     my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1);
1002     $taxproductnum = $class_taxproductnum
1003       if $class_taxproductnum
1004   }
1005   
1006   $part_pkg_taxproduct =
1007     qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1008
1009   unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) {
1010     $taxproductnum = $self->taxproductnum;
1011     $part_pkg_taxproduct =
1012       qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } );
1013   }
1014
1015   $part_pkg_taxproduct;
1016 }
1017
1018 =item taxproduct_description [ CLASS ]
1019
1020 Returns the description of the associated tax product for this package
1021 definition (see L<FS::part_pkg_taxproduct>).
1022
1023 =cut
1024
1025 sub taxproduct_description {
1026   my $self = shift;
1027   my $part_pkg_taxproduct = $self->taxproduct(@_);
1028   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
1029 }
1030
1031 =item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
1032
1033 Returns the package to taxrate m2m records for this package in the location
1034 specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
1035 CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
1036 (see L<FS::usage_class>).
1037
1038 =cut
1039
1040 sub _expand_cch_taxproductnum {
1041   my $self = shift;
1042   my $class = shift;
1043   my $part_pkg_taxproduct = $self->taxproduct($class);
1044
1045   my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
1046                          ? ( split ':', $part_pkg_taxproduct->taxproduct )
1047                          : ()
1048                      );
1049   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
1050   my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
1051                       OR taxproduct = '$a:$b:$c:'
1052                       OR taxproduct = '$a:$b:".":$d'
1053                       OR taxproduct = '$a:$b:".":' )";
1054   map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
1055                                        'hashref'   => { 'data_vendor'=>'cch' },
1056                                        'extra_sql' => $extra_sql,
1057                                    } );
1058                                      
1059 }
1060
1061 sub part_pkg_taxrate {
1062   my $self = shift;
1063   my ($data_vendor, $geocode, $class) = @_;
1064
1065   my $dbh = dbh;
1066   my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
1067                   dbh->quote($data_vendor);
1068   
1069   # CCH oddness in m2m
1070   $extra_sql .= ' AND ('.
1071     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
1072                  qw(10 5 2)
1073         ).
1074     ')';
1075   # much more CCH oddness in m2m -- this is kludgy
1076   my @tpnums = $self->_expand_cch_taxproductnum($class);
1077   if (scalar(@tpnums)) {
1078     $extra_sql .= ' AND ('.
1079                             join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
1080                        ')';
1081   } else {
1082     $extra_sql .= ' AND ( 0 = 1 )';
1083   }
1084
1085   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
1086   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
1087   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
1088
1089   # should qsearch preface columns with the table to facilitate joins?
1090   qsearch( { 'table'     => 'part_pkg_taxrate',
1091              'select'    => $select,
1092              'hashref'   => { # 'data_vendor'   => $data_vendor,
1093                               # 'taxproductnum' => $self->taxproductnum,
1094                             },
1095              'addl_from' => $addl_from,
1096              'extra_sql' => $extra_sql,
1097              'order_by'  => $order_by,
1098          } );
1099 }
1100
1101 =item _rebless
1102
1103 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
1104 PLAN is the object's I<plan> field.  There should be better docs
1105 on how to create new price plans, but until then, see L</NEW PLAN CLASSES>.
1106
1107 =cut
1108
1109 sub _rebless {
1110   my $self = shift;
1111   my $plan = $self->plan;
1112   unless ( $plan ) {
1113     cluck "no price plan found for pkgpart ". $self->pkgpart. "\n"
1114       if $DEBUG;
1115     return $self;
1116   }
1117   return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass
1118   my $class = ref($self). "::$plan";
1119   warn "reblessing $self into $class" if $DEBUG;
1120   eval "use $class;";
1121   die $@ if $@;
1122   bless($self, $class) unless $@;
1123   $self;
1124 }
1125
1126 #fallbacks that eval the setup and recur fields, for backwards compat
1127
1128 sub calc_setup {
1129   my $self = shift;
1130   warn 'no price plan class for '. $self->plan. ", eval-ing setup\n";
1131   $self->_calc_eval('setup', @_);
1132 }
1133
1134 sub calc_recur {
1135   my $self = shift;
1136   warn 'no price plan class for '. $self->plan. ", eval-ing recur\n";
1137   $self->_calc_eval('recur', @_);
1138 }
1139
1140 use vars qw( $sdate @details );
1141 sub _calc_eval {
1142   #my( $self, $field, $cust_pkg ) = @_;
1143   my( $self, $field, $cust_pkg, $sdateref, $detailsref ) = @_;
1144   *sdate = $sdateref;
1145   *details = $detailsref;
1146   $self->$field() =~ /^(.*)$/
1147     or die "Illegal $field (pkgpart ". $self->pkgpart. '): '.
1148             $self->$field(). "\n";
1149   my $prog = $1;
1150   return 0 if $prog =~ /^\s*$/;
1151   my $value = eval $prog;
1152   die $@ if $@;
1153   $value;
1154 }
1155
1156 #fallback that return 0 for old legacy packages with no plan
1157
1158 sub calc_remain { 0; }
1159 sub calc_cancel { 0; }
1160 sub calc_units  { 0; }
1161
1162 #fallback for everything except bulk.pm
1163 sub hide_svc_detail { 0; }
1164
1165 =item format OPTION DATA
1166
1167 Returns data formatted according to the function 'format' described
1168 in the plan info.  Returns DATA if no such function exists.
1169
1170 =cut
1171
1172 sub format {
1173   my ($self, $option, $data) = (shift, shift, shift);
1174   if (exists($plans{$self->plan}->{fields}->{$option}{format})) {
1175     &{$plans{$self->plan}->{fields}->{$option}{format}}($data);
1176   }else{
1177     $data;
1178   }
1179 }
1180
1181 =item parse OPTION DATA
1182
1183 Returns data parsed according to the function 'parse' described
1184 in the plan info.  Returns DATA if no such function exists.
1185
1186 =cut
1187
1188 sub parse {
1189   my ($self, $option, $data) = (shift, shift, shift);
1190   if (exists($plans{$self->plan}->{fields}->{$option}{parse})) {
1191     &{$plans{$self->plan}->{fields}->{$option}{parse}}($data);
1192   }else{
1193     $data;
1194   }
1195 }
1196
1197 =back
1198
1199 =cut
1200
1201 =head1 CLASS METHODS
1202
1203 =over 4
1204
1205 =cut
1206
1207 # _upgrade_data
1208 #
1209 # Used by FS::Upgrade to migrate to a new database.
1210
1211 sub _upgrade_data { # class method
1212   my($class, %opts) = @_;
1213
1214   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
1215
1216   my @part_pkg = qsearch({
1217     'table'     => 'part_pkg',
1218     'extra_sql' => "WHERE ". join(' OR ',
1219                      ( map "($_ IS NOT NULL AND $_ != '' )",
1220                            qw( plandata setup recur ) ),
1221                      'plan IS NULL', "plan = '' ",
1222                    ),
1223   });
1224
1225   foreach my $part_pkg (@part_pkg) {
1226
1227     unless ( $part_pkg->plan ) {
1228       $part_pkg->plan('flat');
1229     }
1230
1231     if ( length($part_pkg->option('setup_fee')) == 0 
1232          && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) {
1233
1234       my $opt = new FS::part_pkg_option {
1235         'pkgpart'     => $part_pkg->pkgpart,
1236         'optionname'  => 'setup_fee',
1237         'optionvalue' => $1,
1238       };
1239       my $error = $opt->insert;
1240       die $error if $error;
1241
1242
1243       #} else {
1244       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1245       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1246     }
1247     $part_pkg->setup('');
1248
1249     if ( length($part_pkg->option('recur_fee')) == 0
1250          && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) {
1251
1252         my $opt = new FS::part_pkg_option {
1253           'pkgpart'     => $part_pkg->pkgpart,
1254           'optionname'  => 'recur_fee',
1255           'optionvalue' => $1,
1256         };
1257         my $error = $opt->insert;
1258         die $error if $error;
1259
1260
1261       #} else {
1262       #  die "Can't parse part_pkg.setup for fee; convert pkgnum ".
1263       #      $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n";
1264     }
1265     $part_pkg->recur('');
1266
1267     $part_pkg->replace; #this should take care of plandata, right?
1268
1269   }
1270
1271   # now upgrade to the explicit custom flag
1272
1273   @part_pkg = qsearch({
1274     'table'     => 'part_pkg',
1275     'hashref'   => { disabled => 'Y', custom => '' },
1276     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
1277   });
1278
1279   foreach my $part_pkg (@part_pkg) {
1280     my $new = new FS::part_pkg { $part_pkg->hash };
1281     $new->custom('Y');
1282     my $comment = $part_pkg->comment;
1283     $comment =~ s/^\(CUSTOM\) //;
1284     $new->comment($comment);
1285
1286     my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc };
1287     my $primary = $part_pkg->svcpart;
1288     my $options = { $part_pkg->options };
1289
1290     my $error = $new->replace( $part_pkg,
1291                                'pkg_svc'     => $pkg_svc,
1292                                'primary_svc' => $primary,
1293                                'options'     => $options,
1294                              );
1295     die $error if $error;
1296   }
1297
1298 }
1299
1300 =item curuser_pkgs_sql
1301
1302 Returns an SQL fragment for searching for packages the current user can
1303 use, either via part_pkg.agentnum directly, or via agent type (see
1304 L<FS::type_pkgs>).
1305
1306 =cut
1307
1308 sub curuser_pkgs_sql {
1309   my $class = shift;
1310
1311   $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums );
1312
1313 }
1314
1315 =item agent_pkgs_sql AGENT | AGENTNUM, ...
1316
1317 Returns an SQL fragment for searching for packages the provided agent or agents
1318 can use, either via part_pkg.agentnum directly, or via agent type (see
1319 L<FS::type_pkgs>).
1320
1321 =cut
1322
1323 sub agent_pkgs_sql {
1324   my $class = shift;  #i'm a class method, not a sub (the question is... why??)
1325   my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_;
1326
1327   $class->_pkgs_sql(@agentnums); #is this why
1328
1329 }
1330
1331 sub _pkgs_sql {
1332   my( $class, @agentnums ) = @_;
1333   my $agentnums = join(',', @agentnums);
1334
1335   "
1336     (
1337       agentnum IS NOT NULL
1338       OR
1339       0 < ( SELECT COUNT(*)
1340               FROM type_pkgs
1341                 LEFT JOIN agent_type USING ( typenum )
1342                 LEFT JOIN agent AS typeagent USING ( typenum )
1343               WHERE type_pkgs.pkgpart = part_pkg.pkgpart
1344                 AND typeagent.agentnum IN ($agentnums)
1345           )
1346     )
1347   ";
1348
1349 }
1350
1351 =back
1352
1353 =head1 SUBROUTINES
1354
1355 =over 4
1356
1357 =item plan_info
1358
1359 =cut
1360
1361 #false laziness w/part_export & cdr
1362 my %info;
1363 foreach my $INC ( @INC ) {
1364   warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
1365   foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
1366     warn "attempting to load plan info from $file\n" if $DEBUG;
1367     $file =~ /\/(\w+)\.pm$/ or do {
1368       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
1369       next;
1370     };
1371     my $mod = $1;
1372     my $info = eval "use FS::part_pkg::$mod; ".
1373                     "\\%FS::part_pkg::$mod\::info;";
1374     if ( $@ ) {
1375       die "error using FS::part_pkg::$mod (skipping): $@\n" if $@;
1376       next;
1377     }
1378     unless ( keys %$info ) {
1379       warn "no %info hash found in FS::part_pkg::$mod, skipping\n";
1380       next;
1381     }
1382     warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG;
1383     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
1384       warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG;
1385       next;
1386     }
1387     $info{$mod} = $info;
1388   }
1389 }
1390
1391 tie %plans, 'Tie::IxHash',
1392   map  { $_ => $info{$_} }
1393   sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} }
1394   keys %info;
1395
1396 sub plan_info {
1397   \%plans;
1398 }
1399
1400
1401 =back
1402
1403 =head1 NEW PLAN CLASSES
1404
1405 A module should be added in FS/FS/part_pkg/  Eventually, an example may be
1406 found in eg/plan_template.pm.  Until then, it is suggested that you use the
1407 other modules in FS/FS/part_pkg/ as a guide.
1408
1409 =head1 BUGS
1410
1411 The delete method is unimplemented.
1412
1413 setup and recur semantics are not yet defined (and are implemented in
1414 FS::cust_bill.  hmm.).  now they're deprecated and need to go.
1415
1416 plandata should go
1417
1418 part_pkg_taxrate is Pg specific
1419
1420 replace should be smarter about managing the related tables (options, pkg_svc)
1421
1422 =head1 SEE ALSO
1423
1424 L<FS::Record>, L<FS::cust_pkg>, L<FS::type_pkgs>, L<FS::pkg_svc>, L<Safe>.
1425 schema.html from the base documentation.
1426
1427 =cut
1428
1429 1;
1430