new package editor
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use vars qw(@ISA $disable_agentcheck $DEBUG);
5 use Scalar::Util qw( blessed );
6 use List::Util qw(max);
7 use Tie::IxHash;
8 use FS::UID qw( getotaker dbh );
9 use FS::Misc qw( send_email );
10 use FS::Record qw( qsearch qsearchs );
11 use FS::m2m_Common;
12 use FS::cust_main_Mixin;
13 use FS::cust_svc;
14 use FS::part_pkg;
15 use FS::cust_main;
16 use FS::type_pkgs;
17 use FS::pkg_svc;
18 use FS::cust_bill_pkg;
19 use FS::cust_event;
20 use FS::h_cust_svc;
21 use FS::reg_code;
22 use FS::part_svc;
23 use FS::cust_pkg_reason;
24 use FS::reason;
25 use FS::UI::Web;
26
27 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
28 # setup }
29 # because they load configuration by setting FS::UID::callback (see TODO)
30 use FS::svc_acct;
31 use FS::svc_domain;
32 use FS::svc_www;
33 use FS::svc_forward;
34
35 # for sending cancel emails in sub cancel
36 use FS::Conf;
37
38 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
39
40 $DEBUG = 0;
41
42 $disable_agentcheck = 0;
43
44 sub _cache {
45   my $self = shift;
46   my ( $hashref, $cache ) = @_;
47   #if ( $hashref->{'pkgpart'} ) {
48   if ( $hashref->{'pkg'} ) {
49     # #@{ $self->{'_pkgnum'} } = ();
50     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
51     # $self->{'_pkgpart'} = $subcache;
52     # #push @{ $self->{'_pkgnum'} },
53     #   FS::part_pkg->new_or_cached($hashref, $subcache);
54     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
55   }
56   if ( exists $hashref->{'svcnum'} ) {
57     #@{ $self->{'_pkgnum'} } = ();
58     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
59     $self->{'_svcnum'} = $subcache;
60     #push @{ $self->{'_pkgnum'} },
61     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
62   }
63 }
64
65 =head1 NAME
66
67 FS::cust_pkg - Object methods for cust_pkg objects
68
69 =head1 SYNOPSIS
70
71   use FS::cust_pkg;
72
73   $record = new FS::cust_pkg \%hash;
74   $record = new FS::cust_pkg { 'column' => 'value' };
75
76   $error = $record->insert;
77
78   $error = $new_record->replace($old_record);
79
80   $error = $record->delete;
81
82   $error = $record->check;
83
84   $error = $record->cancel;
85
86   $error = $record->suspend;
87
88   $error = $record->unsuspend;
89
90   $part_pkg = $record->part_pkg;
91
92   @labels = $record->labels;
93
94   $seconds = $record->seconds_since($timestamp);
95
96   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
97   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
98
99 =head1 DESCRIPTION
100
101 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
102 inherits from FS::Record.  The following fields are currently supported:
103
104 =over 4
105
106 =item pkgnum - primary key (assigned automatically for new billing items)
107
108 =item custnum - Customer (see L<FS::cust_main>)
109
110 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
111
112 =item setup - date
113
114 =item bill - date (next bill date)
115
116 =item last_bill - last bill date
117
118 =item adjourn - date
119
120 =item susp - date
121
122 =item expire - date
123
124 =item cancel - date
125
126 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
127
128 =item manual_flag - If this field is set to 1, disables the automatic
129 unsuspension of this package when using the B<unsuspendauto> config file.
130
131 =back
132
133 Note: setup, bill, adjourn, susp, expire and cancel are specified as UNIX timestamps;
134 see L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for
135 conversion functions.
136
137 =head1 METHODS
138
139 =over 4
140
141 =item new HASHREF
142
143 Create a new billing item.  To add the item to the database, see L<"insert">.
144
145 =cut
146
147 sub table { 'cust_pkg'; }
148 sub cust_linked { $_[0]->cust_main_custnum; } 
149 sub cust_unlinked_msg {
150   my $self = shift;
151   "WARNING: can't find cust_main.custnum ". $self->custnum.
152   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
153 }
154
155 =item insert [ OPTION => VALUE ... ]
156
157 Adds this billing item to the database ("Orders" the item).  If there is an
158 error, returns the error, otherwise returns false.
159
160 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
161 will be used to look up the package definition and agent restrictions will be
162 ignored.
163
164 If the additional field I<refnum> is defined, an FS::pkg_referral record will
165 be created and inserted.  Multiple FS::pkg_referral records can be created by
166 setting I<refnum> to an array reference of refnums or a hash reference with
167 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
168 record will be created corresponding to cust_main.refnum.
169
170 The following options are available: I<change>
171
172 I<change>, if set true, supresses any referral credit to a referring customer.
173
174 =cut
175
176 sub insert {
177   my( $self, %options ) = @_;
178
179   local $SIG{HUP} = 'IGNORE';
180   local $SIG{INT} = 'IGNORE';
181   local $SIG{QUIT} = 'IGNORE';
182   local $SIG{TERM} = 'IGNORE';
183   local $SIG{TSTP} = 'IGNORE';
184   local $SIG{PIPE} = 'IGNORE';
185
186   my $oldAutoCommit = $FS::UID::AutoCommit;
187   local $FS::UID::AutoCommit = 0;
188   my $dbh = dbh;
189
190   my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
191   if ( $error ) {
192     $dbh->rollback if $oldAutoCommit;
193     return $error;
194   }
195
196   $self->refnum($self->cust_main->refnum) unless $self->refnum;
197   $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
198   $self->process_m2m( 'link_table'   => 'pkg_referral',
199                       'target_table' => 'part_referral',
200                       'params'       => $self->refnum,
201                     );
202
203   #if ( $self->reg_code ) {
204   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
205   #  $error = $reg_code->delete;
206   #  if ( $error ) {
207   #    $dbh->rollback if $oldAutoCommit;
208   #    return $error;
209   #  }
210   #}
211
212   my $conf = new FS::Conf;
213   my $cust_main = $self->cust_main;
214   my $part_pkg = $self->part_pkg;
215   if ( $conf->exists('referral_credit')
216        && $cust_main->referral_custnum
217        && ! $options{'change'}
218        && $part_pkg->freq !~ /^0\D?$/
219      )
220   {
221     my $referring_cust_main = $cust_main->referring_cust_main;
222     if ( $referring_cust_main->status ne 'cancelled' ) {
223       my $error;
224       if ( $part_pkg->freq !~ /^\d+$/ ) {
225         warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
226              ' for package '. $self->pkgnum.
227              ' ( customer '. $self->custnum. ')'.
228              ' - One-time referral credits not (yet) available for '.
229              ' packages with '. $part_pkg->freq_pretty. ' frequency';
230       } else {
231
232         my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
233         my $error =
234           $referring_cust_main->
235             credit( $amount,
236                     'Referral credit for '.$cust_main->name,
237                     'reason_type' => $conf->config('referral_credit_type')
238                   );
239         if ( $error ) {
240           $dbh->rollback if $oldAutoCommit;
241           return "Error crediting customer ". $cust_main->referral_custnum.
242                " for referral: $error";
243         }
244
245       }
246
247     }
248   }
249
250   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
251     my $queue = new FS::queue {
252       'job'     => 'FS::cust_main::queueable_print',
253     };
254     $error = $queue->insert(
255       'custnum'  => $self->custnum,
256       'template' => 'welcome_letter',
257     );
258
259     if ($error) {
260       warn "can't send welcome letter: $error";
261     }
262
263   }
264
265   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
266   '';
267
268 }
269
270 =item delete
271
272 This method now works but you probably shouldn't use it.
273
274 You don't want to delete billing items, because there would then be no record
275 the customer ever purchased the item.  Instead, see the cancel method.
276
277 =cut
278
279 #sub delete {
280 #  return "Can't delete cust_pkg records!";
281 #}
282
283 =item replace OLD_RECORD
284
285 Replaces the OLD_RECORD with this one in the database.  If there is an error,
286 returns the error, otherwise returns false.
287
288 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
289
290 Changing pkgpart may have disasterous effects.  See the order subroutine.
291
292 setup and bill are normally updated by calling the bill method of a customer
293 object (see L<FS::cust_main>).
294
295 suspend is normally updated by the suspend and unsuspend methods.
296
297 cancel is normally updated by the cancel method (and also the order subroutine
298 in some cases).
299
300 Calls 
301
302 =cut
303
304 sub replace {
305   my $new = shift;
306
307   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
308               ? shift
309               : $new->replace_old;
310
311   my $options = 
312     ( ref($_[0]) eq 'HASH' )
313       ? shift
314       : { @_ };
315
316   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
317   return "Can't change otaker!" if $old->otaker ne $new->otaker;
318
319   #allow this *sigh*
320   #return "Can't change setup once it exists!"
321   #  if $old->getfield('setup') &&
322   #     $old->getfield('setup') != $new->getfield('setup');
323
324   #some logic for bill, susp, cancel?
325
326   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
327
328   local $SIG{HUP} = 'IGNORE';
329   local $SIG{INT} = 'IGNORE';
330   local $SIG{QUIT} = 'IGNORE';
331   local $SIG{TERM} = 'IGNORE';
332   local $SIG{TSTP} = 'IGNORE';
333   local $SIG{PIPE} = 'IGNORE';
334
335   my $oldAutoCommit = $FS::UID::AutoCommit;
336   local $FS::UID::AutoCommit = 0;
337   my $dbh = dbh;
338
339   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
340     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
341       my $error = $new->insert_reason( 'reason' => $options->{'reason'},
342                                        'date'   => $new->$method,
343                                      );
344       if ( $error ) {
345         dbh->rollback if $oldAutoCommit;
346         return "Error inserting cust_pkg_reason: $error";
347       }
348     }
349   }
350
351   #save off and freeze RADIUS attributes for any associated svc_acct records
352   my @svc_acct = ();
353   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
354
355                 #also check for specific exports?
356                 # to avoid spurious modify export events
357     @svc_acct = map  { $_->svc_x }
358                 grep { $_->part_svc->svcdb eq 'svc_acct' }
359                      $old->cust_svc;
360
361     $_->snapshot foreach @svc_acct;
362
363   }
364
365   my $error = $new->SUPER::replace($old,
366                                    $options->{options} ? $options->{options} : ()
367                                   );
368   if ( $error ) {
369     $dbh->rollback if $oldAutoCommit;
370     return $error;
371   }
372
373   #for prepaid packages,
374   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
375   foreach my $old_svc_acct ( @svc_acct ) {
376     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
377     my $s_error = $new_svc_acct->replace($old_svc_acct);
378     if ( $s_error ) {
379       $dbh->rollback if $oldAutoCommit;
380       return $s_error;
381     }
382   }
383
384   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
385   '';
386
387 }
388
389 =item check
390
391 Checks all fields to make sure this is a valid billing item.  If there is an
392 error, returns the error, otherwise returns false.  Called by the insert and
393 replace methods.
394
395 =cut
396
397 sub check {
398   my $self = shift;
399
400   my $error = 
401     $self->ut_numbern('pkgnum')
402     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
403     || $self->ut_numbern('pkgpart')
404     || $self->ut_numbern('setup')
405     || $self->ut_numbern('bill')
406     || $self->ut_numbern('susp')
407     || $self->ut_numbern('cancel')
408     || $self->ut_numbern('adjourn')
409     || $self->ut_numbern('expire')
410   ;
411   return $error if $error;
412
413   if ( $self->reg_code ) {
414
415     unless ( grep { $self->pkgpart == $_->pkgpart }
416              map  { $_->reg_code_pkg }
417              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
418                                      'agentnum' => $self->cust_main->agentnum })
419            ) {
420       return "Unknown registration code";
421     }
422
423   } elsif ( $self->promo_code ) {
424
425     my $promo_part_pkg =
426       qsearchs('part_pkg', {
427         'pkgpart'    => $self->pkgpart,
428         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
429       } );
430     return 'Unknown promotional code' unless $promo_part_pkg;
431
432   } else { 
433
434     unless ( $disable_agentcheck ) {
435       my $agent =
436         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
437       my $pkgpart_href = $agent->pkgpart_hashref;
438       return "agent ". $agent->agentnum.
439              " can't purchase pkgpart ". $self->pkgpart
440         unless $pkgpart_href->{ $self->pkgpart };
441     }
442
443     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
444     return $error if $error;
445
446   }
447
448   $self->otaker(getotaker) unless $self->otaker;
449   $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
450   $self->otaker($1);
451
452   if ( $self->dbdef_table->column('manual_flag') ) {
453     $self->manual_flag('') if $self->manual_flag eq ' ';
454     $self->manual_flag =~ /^([01]?)$/
455       or return "Illegal manual_flag ". $self->manual_flag;
456     $self->manual_flag($1);
457   }
458
459   $self->SUPER::check;
460 }
461
462 =item cancel [ OPTION => VALUE ... ]
463
464 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
465 in this package, then cancels the package itself (sets the cancel field to
466 now).
467
468 Available options are:
469
470 =over 4
471
472 =item quiet - can be set true to supress email cancellation notices.
473
474 =item time -  can be set to cancel the package based on a specific future or historical date.  Using time ensures that the remaining amount is calculated correctly.  Note however that this is an immediate cancel and just changes the date.  You are PROBABLY looking to expire the account instead of using this.
475
476 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
477
478 =back
479
480 If there is an error, returns the error, otherwise returns false.
481
482 =cut
483
484 sub cancel {
485   my( $self, %options ) = @_;
486
487   warn "cust_pkg::cancel called with options".
488        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
489     if $DEBUG;
490
491   local $SIG{HUP} = 'IGNORE';
492   local $SIG{INT} = 'IGNORE';
493   local $SIG{QUIT} = 'IGNORE'; 
494   local $SIG{TERM} = 'IGNORE';
495   local $SIG{TSTP} = 'IGNORE';
496   local $SIG{PIPE} = 'IGNORE';
497
498   my $oldAutoCommit = $FS::UID::AutoCommit;
499   local $FS::UID::AutoCommit = 0;
500   my $dbh = dbh;
501   
502   my $cancel_time = $options{'time'} || time;
503
504   my $error;
505
506   if ( $options{'reason'} ) {
507     $error = $self->insert_reason( 'reason' => $options{'reason'} );
508     if ( $error ) {
509       dbh->rollback if $oldAutoCommit;
510       return "Error inserting cust_pkg_reason: $error";
511     }
512   }
513
514   my %svc;
515   foreach my $cust_svc (
516     #schwartz
517     map  { $_->[0] }
518     sort { $a->[1] <=> $b->[1] }
519     map  { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
520     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
521   ) {
522
523     my $error = $cust_svc->cancel;
524
525     if ( $error ) {
526       $dbh->rollback if $oldAutoCommit;
527       return "Error cancelling cust_svc: $error";
528     }
529   }
530
531   unless ( $self->getfield('cancel') ) {
532     # Add a credit for remaining service
533     my $remaining_value = $self->calc_remain(time=>$cancel_time);
534     if ( $remaining_value > 0 && !$options{'no_credit'} ) {
535       my $conf = new FS::Conf;
536       my $error = $self->cust_main->credit(
537         $remaining_value,
538         'Credit for unused time on '. $self->part_pkg->pkg,
539         'reason_type' => $conf->config('cancel_credit_type'),
540       );
541       if ($error) {
542         $dbh->rollback if $oldAutoCommit;
543         return "Error crediting customer \$$remaining_value for unused time on".
544           $self->part_pkg->pkg. ": $error";
545       }                                                                          
546     }                                                                            
547     my %hash = $self->hash;
548     $hash{'cancel'} = $cancel_time;
549     my $new = new FS::cust_pkg ( \%hash );
550     $error = $new->replace( $self, options => { $self->options } );
551     if ( $error ) {
552       $dbh->rollback if $oldAutoCommit;
553       return $error;
554     }
555   }
556
557   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
558
559   my $conf = new FS::Conf;
560   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
561   if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
562     my $conf = new FS::Conf;
563     my $error = send_email(
564       'from'    => $conf->config('invoice_from'),
565       'to'      => \@invoicing_list,
566       'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
567       'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
568     );
569     #should this do something on errors?
570   }
571
572   ''; #no errors
573
574 }
575
576 =item cancel_if_expired [ NOW_TIMESTAMP ]
577
578 Cancels this package if its expire date has been reached.
579
580 =cut
581
582 sub cancel_if_expired {
583   my $self = shift;
584   my $time = shift || time;
585   return '' unless $self->expire && $self->expire <= $time;
586   my $error = $self->cancel;
587   if ( $error ) {
588     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
589            $self->custnum. ": $error";
590   }
591   '';
592 }
593
594 =item suspend  [ OPTION => VALUE ... ]
595
596 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
597 package, then suspends the package itself (sets the susp field to now).
598
599 Available options are:
600
601 =over 4
602
603 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
604
605 =back
606
607 If there is an error, returns the error, otherwise returns false.
608
609 =cut
610
611 sub suspend {
612   my( $self, %options ) = @_;
613
614   local $SIG{HUP} = 'IGNORE';
615   local $SIG{INT} = 'IGNORE';
616   local $SIG{QUIT} = 'IGNORE'; 
617   local $SIG{TERM} = 'IGNORE';
618   local $SIG{TSTP} = 'IGNORE';
619   local $SIG{PIPE} = 'IGNORE';
620
621   my $oldAutoCommit = $FS::UID::AutoCommit;
622   local $FS::UID::AutoCommit = 0;
623   my $dbh = dbh;
624
625   my $error;
626
627   if ( $options{'reason'} ) {
628     $error = $self->insert_reason( 'reason' => $options{'reason'} );
629     if ( $error ) {
630       dbh->rollback if $oldAutoCommit;
631       return "Error inserting cust_pkg_reason: $error";
632     }
633   }
634
635   foreach my $cust_svc (
636     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
637   ) {
638     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
639
640     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
641       $dbh->rollback if $oldAutoCommit;
642       return "Illegal svcdb value in part_svc!";
643     };
644     my $svcdb = $1;
645     require "FS/$svcdb.pm";
646
647     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
648     if ($svc) {
649       $error = $svc->suspend;
650       if ( $error ) {
651         $dbh->rollback if $oldAutoCommit;
652         return $error;
653       }
654     }
655
656   }
657
658   unless ( $self->getfield('susp') ) {
659     my %hash = $self->hash;
660     $hash{'susp'} = time;
661     my $new = new FS::cust_pkg ( \%hash );
662     $error = $new->replace( $self, options => { $self->options } );
663     if ( $error ) {
664       $dbh->rollback if $oldAutoCommit;
665       return $error;
666     }
667   }
668
669   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
670
671   ''; #no errors
672 }
673
674 =item unsuspend [ OPTION => VALUE ... ]
675
676 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
677 package, then unsuspends the package itself (clears the susp field and the
678 adjourn field if it is in the past).
679
680 Available options are: I<adjust_next_bill>.
681
682 I<adjust_next_bill> can be set true to adjust the next bill date forward by
683 the amount of time the account was inactive.  This was set true by default
684 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
685 explicitly requested.  Price plans for which this makes sense (anniversary-date
686 based than prorate or subscription) could have an option to enable this
687 behaviour?
688
689 If there is an error, returns the error, otherwise returns false.
690
691 =cut
692
693 sub unsuspend {
694   my( $self, %opt ) = @_;
695   my $error;
696
697   local $SIG{HUP} = 'IGNORE';
698   local $SIG{INT} = 'IGNORE';
699   local $SIG{QUIT} = 'IGNORE'; 
700   local $SIG{TERM} = 'IGNORE';
701   local $SIG{TSTP} = 'IGNORE';
702   local $SIG{PIPE} = 'IGNORE';
703
704   my $oldAutoCommit = $FS::UID::AutoCommit;
705   local $FS::UID::AutoCommit = 0;
706   my $dbh = dbh;
707
708   foreach my $cust_svc (
709     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
710   ) {
711     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
712
713     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
714       $dbh->rollback if $oldAutoCommit;
715       return "Illegal svcdb value in part_svc!";
716     };
717     my $svcdb = $1;
718     require "FS/$svcdb.pm";
719
720     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
721     if ($svc) {
722       $error = $svc->unsuspend;
723       if ( $error ) {
724         $dbh->rollback if $oldAutoCommit;
725         return $error;
726       }
727     }
728
729   }
730
731   unless ( ! $self->getfield('susp') ) {
732     my %hash = $self->hash;
733     my $inactive = time - $hash{'susp'};
734
735     my $conf = new FS::Conf;
736
737     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
738       if ( $opt{'adjust_next_bill'}
739            || $conf->config('unsuspend-always_adjust_next_bill_date') )
740       && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
741
742     $hash{'susp'} = '';
743     $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
744     my $new = new FS::cust_pkg ( \%hash );
745     $error = $new->replace( $self, options => { $self->options } );
746     if ( $error ) {
747       $dbh->rollback if $oldAutoCommit;
748       return $error;
749     }
750   }
751
752   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
753
754   ''; #no errors
755 }
756
757 =item last_bill
758
759 Returns the last bill date, or if there is no last bill date, the setup date.
760 Useful for billing metered services.
761
762 =cut
763
764 sub last_bill {
765   my $self = shift;
766   if ( $self->dbdef_table->column('last_bill') ) {
767     return $self->setfield('last_bill', $_[0]) if @_;
768     return $self->getfield('last_bill') if $self->getfield('last_bill');
769   }    
770   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
771                                                   'edate'  => $self->bill,  } );
772   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
773 }
774
775 =item last_reason
776
777 Returns the most recent FS::reason associated with the package.
778
779 =cut
780
781 sub last_reason {
782   my $self = shift;
783   my $cust_pkg_reason = qsearchs( {
784                                     'table' => 'cust_pkg_reason',
785                                     'hashref' => { 'pkgnum' => $self->pkgnum, },
786                                     'extra_sql'=> 'ORDER BY date DESC LIMIT 1',
787                                   } );
788   qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
789     if $cust_pkg_reason;
790 }
791
792 =item part_pkg
793
794 Returns the definition for this billing item, as an FS::part_pkg object (see
795 L<FS::part_pkg>).
796
797 =cut
798
799 sub part_pkg {
800   my $self = shift;
801   #exists( $self->{'_pkgpart'} )
802   $self->{'_pkgpart'}
803     ? $self->{'_pkgpart'}
804     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
805 }
806
807 =item old_cust_pkg
808
809 Returns the cancelled package this package was changed from, if any.
810
811 =cut
812
813 sub old_cust_pkg {
814   my $self = shift;
815   return '' unless $self->change_pkgnum;
816   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
817 }
818
819 =item calc_setup
820
821 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
822 item.
823
824 =cut
825
826 sub calc_setup {
827   my $self = shift;
828   $self->part_pkg->calc_setup($self, @_);
829 }
830
831 =item calc_recur
832
833 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
834 item.
835
836 =cut
837
838 sub calc_recur {
839   my $self = shift;
840   $self->part_pkg->calc_recur($self, @_);
841 }
842
843 =item calc_remain
844
845 Calls the I<calc_remain> of the FS::part_pkg object associated with this
846 billing item.
847
848 =cut
849
850 sub calc_remain {
851   my $self = shift;
852   $self->part_pkg->calc_remain($self, @_);
853 }
854
855 =item calc_cancel
856
857 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
858 billing item.
859
860 =cut
861
862 sub calc_cancel {
863   my $self = shift;
864   $self->part_pkg->calc_cancel($self, @_);
865 }
866
867 =item cust_bill_pkg
868
869 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
870
871 =cut
872
873 sub cust_bill_pkg {
874   my $self = shift;
875   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
876 }
877
878 =item cust_event
879
880 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
881
882 =cut
883
884 #false laziness w/cust_bill.pm
885 sub cust_event {
886   my $self = shift;
887   qsearch({
888     'table'     => 'cust_event',
889     'addl_from' => 'JOIN part_event USING ( eventpart )',
890     'hashref'   => { 'tablenum' => $self->pkgnum },
891     'extra_sql' => " AND eventtable = 'cust_pkg' ",
892   });
893 }
894
895 =item num_cust_event
896
897 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
898
899 =cut
900
901 #false laziness w/cust_bill.pm
902 sub num_cust_event {
903   my $self = shift;
904   my $sql =
905     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
906     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
907   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
908   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
909   $sth->fetchrow_arrayref->[0];
910 }
911
912 =item cust_svc [ SVCPART ]
913
914 Returns the services for this package, as FS::cust_svc objects (see
915 L<FS::cust_svc>).  If a svcpart is specified, return only the matching
916 services.
917
918 =cut
919
920 sub cust_svc {
921   my $self = shift;
922
923   if ( @_ ) {
924     return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
925                                   'svcpart' => shift,          } );
926   }
927
928   #if ( $self->{'_svcnum'} ) {
929   #  values %{ $self->{'_svcnum'}->cache };
930   #} else {
931     $self->_sort_cust_svc(
932       [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
933     );
934   #}
935
936 }
937
938 =item overlimit [ SVCPART ]
939
940 Returns the services for this package which have exceeded their
941 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
942 is specified, return only the matching services.
943
944 =cut
945
946 sub overlimit {
947   my $self = shift;
948   grep { $_->overlimit } $self->cust_svc;
949 }
950
951 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] 
952
953 Returns historical services for this package created before END TIMESTAMP and
954 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
955 (see L<FS::h_cust_svc>).
956
957 =cut
958
959 sub h_cust_svc {
960   my $self = shift;
961
962   $self->_sort_cust_svc(
963     [ qsearch( 'h_cust_svc',
964                { 'pkgnum' => $self->pkgnum, },
965                FS::h_cust_svc->sql_h_search(@_),
966              )
967     ]
968   );
969 }
970
971 sub _sort_cust_svc {
972   my( $self, $arrayref ) = @_;
973
974   map  { $_->[0] }
975   sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
976   map {
977         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
978                                              'svcpart' => $_->svcpart     } );
979         [ $_,
980           $pkg_svc ? $pkg_svc->primary_svc : '',
981           $pkg_svc ? $pkg_svc->quantity : 0,
982         ];
983       }
984   @$arrayref;
985
986 }
987
988 =item num_cust_svc [ SVCPART ]
989
990 Returns the number of provisioned services for this package.  If a svcpart is
991 specified, counts only the matching services.
992
993 =cut
994
995 sub num_cust_svc {
996   my $self = shift;
997   my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
998   $sql .= ' AND svcpart = ?' if @_;
999   my $sth = dbh->prepare($sql) or die dbh->errstr;
1000   $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1001   $sth->fetchrow_arrayref->[0];
1002 }
1003
1004 =item available_part_svc 
1005
1006 Returns a list of FS::part_svc objects representing services included in this
1007 package but not yet provisioned.  Each FS::part_svc object also has an extra
1008 field, I<num_avail>, which specifies the number of available services.
1009
1010 =cut
1011
1012 sub available_part_svc {
1013   my $self = shift;
1014   grep { $_->num_avail > 0 }
1015     map {
1016           my $part_svc = $_->part_svc;
1017           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1018             $_->quantity - $self->num_cust_svc($_->svcpart);
1019           $part_svc;
1020         }
1021       $self->part_pkg->pkg_svc;
1022 }
1023
1024 =item part_svc
1025
1026 Returns a list of FS::part_svc objects representing provisioned and available
1027 services included in this package.  Each FS::part_svc object also has the
1028 following extra fields:
1029
1030 =over 4
1031
1032 =item num_cust_svc  (count)
1033
1034 =item num_avail     (quantity - count)
1035
1036 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1037
1038 svcnum
1039 label -> ($cust_svc->label)[1]
1040
1041 =back
1042
1043 =cut
1044
1045 sub part_svc {
1046   my $self = shift;
1047
1048   #XXX some sort of sort order besides numeric by svcpart...
1049   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1050     my $pkg_svc = $_;
1051     my $part_svc = $pkg_svc->part_svc;
1052     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1053     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1054     $part_svc->{'Hash'}{'num_avail'}    =
1055       max( 0, $pkg_svc->quantity - $num_cust_svc );
1056     $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1057     $part_svc;
1058   } $self->part_pkg->pkg_svc;
1059
1060   #extras
1061   push @part_svc, map {
1062     my $part_svc = $_;
1063     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1064     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1065     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
1066     $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1067     $part_svc;
1068   } $self->extra_part_svc;
1069
1070   @part_svc;
1071
1072 }
1073
1074 =item extra_part_svc
1075
1076 Returns a list of FS::part_svc objects corresponding to services in this
1077 package which are still provisioned but not (any longer) available in the
1078 package definition.
1079
1080 =cut
1081
1082 sub extra_part_svc {
1083   my $self = shift;
1084
1085   my $pkgnum  = $self->pkgnum;
1086   my $pkgpart = $self->pkgpart;
1087
1088   qsearch( {
1089     'table'     => 'part_svc',
1090     'hashref'   => {},
1091     'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
1092                                   WHERE pkg_svc.svcpart = part_svc.svcpart 
1093                                     AND pkg_svc.pkgpart = $pkgpart
1094                                     AND quantity > 0 
1095                               )
1096                       AND 0 < ( SELECT count(*)
1097                                   FROM cust_svc
1098                                     LEFT JOIN cust_pkg using ( pkgnum )
1099                                   WHERE cust_svc.svcpart = part_svc.svcpart
1100                                     AND pkgnum = $pkgnum
1101                               )",
1102   } );
1103 }
1104
1105 =item status
1106
1107 Returns a short status string for this package, currently:
1108
1109 =over 4
1110
1111 =item not yet billed
1112
1113 =item one-time charge
1114
1115 =item active
1116
1117 =item suspended
1118
1119 =item cancelled
1120
1121 =back
1122
1123 =cut
1124
1125 sub status {
1126   my $self = shift;
1127
1128   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1129
1130   return 'cancelled' if $self->get('cancel');
1131   return 'suspended' if $self->susp;
1132   return 'not yet billed' unless $self->setup;
1133   return 'one-time charge' if $freq =~ /^(0|$)/;
1134   return 'active';
1135 }
1136
1137 =item statuses
1138
1139 Class method that returns the list of possible status strings for packages
1140 (see L<the status method|/status>).  For example:
1141
1142   @statuses = FS::cust_pkg->statuses();
1143
1144 =cut
1145
1146 tie my %statuscolor, 'Tie::IxHash', 
1147   'not yet billed'  => '000000',
1148   'one-time charge' => '000000',
1149   'active'          => '00CC00',
1150   'suspended'       => 'FF9900',
1151   'cancelled'       => 'FF0000',
1152 ;
1153
1154 sub statuses {
1155   my $self = shift; #could be class...
1156   grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1157                                       # mayble split btw one-time vs. recur
1158     keys %statuscolor;
1159 }
1160
1161 =item statuscolor
1162
1163 Returns a hex triplet color string for this package's status.
1164
1165 =cut
1166
1167 sub statuscolor {
1168   my $self = shift;
1169   $statuscolor{$self->status};
1170 }
1171
1172 =item labels
1173
1174 Returns a list of lists, calling the label method for all services
1175 (see L<FS::cust_svc>) of this billing item.
1176
1177 =cut
1178
1179 sub labels {
1180   my $self = shift;
1181   map { [ $_->label ] } $self->cust_svc;
1182 }
1183
1184 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] 
1185
1186 Like the labels method, but returns historical information on services that
1187 were active as of END_TIMESTAMP and (optionally) not cancelled before
1188 START_TIMESTAMP.
1189
1190 Returns a list of lists, calling the label method for all (historical) services
1191 (see L<FS::h_cust_svc>) of this billing item.
1192
1193 =cut
1194
1195 sub h_labels {
1196   my $self = shift;
1197   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1198 }
1199
1200 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1201
1202 Like h_labels, except returns a simple flat list, and shortens long 
1203 (currently >5) lists of identical services to one line that lists the service
1204 label and the number of individual services rather than individual items.
1205
1206 =cut
1207
1208 sub h_labels_short {
1209   my $self = shift;
1210
1211   my %labels;
1212   #tie %labels, 'Tie::IxHash';
1213   push @{ $labels{$_->[0]} }, $_->[1]
1214     foreach $self->h_labels(@_);
1215   my @labels;
1216   foreach my $label ( keys %labels ) {
1217     my @values = @{ $labels{$label} };
1218     my $num = scalar(@values);
1219     if ( $num > 5 ) {
1220       push @labels, "$label ($num)";
1221     } else {
1222       push @labels, map { "$label: $_" } @values;
1223     }
1224   }
1225
1226  @labels;
1227
1228 }
1229
1230 =item cust_main
1231
1232 Returns the parent customer object (see L<FS::cust_main>).
1233
1234 =cut
1235
1236 sub cust_main {
1237   my $self = shift;
1238   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1239 }
1240
1241 =item seconds_since TIMESTAMP
1242
1243 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1244 package have been online since TIMESTAMP, according to the session monitor.
1245
1246 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1247 L<Time::Local> and L<Date::Parse> for conversion functions.
1248
1249 =cut
1250
1251 sub seconds_since {
1252   my($self, $since) = @_;
1253   my $seconds = 0;
1254
1255   foreach my $cust_svc (
1256     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1257   ) {
1258     $seconds += $cust_svc->seconds_since($since);
1259   }
1260
1261   $seconds;
1262
1263 }
1264
1265 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1266
1267 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1268 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1269 (exclusive).
1270
1271 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1272 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
1273 functions.
1274
1275
1276 =cut
1277
1278 sub seconds_since_sqlradacct {
1279   my($self, $start, $end) = @_;
1280
1281   my $seconds = 0;
1282
1283   foreach my $cust_svc (
1284     grep {
1285       my $part_svc = $_->part_svc;
1286       $part_svc->svcdb eq 'svc_acct'
1287         && scalar($part_svc->part_export('sqlradius'));
1288     } $self->cust_svc
1289   ) {
1290     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1291   }
1292
1293   $seconds;
1294
1295 }
1296
1297 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1298
1299 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1300 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1301 TIMESTAMP_END
1302 (exclusive).
1303
1304 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1305 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
1306 functions.
1307
1308 =cut
1309
1310 sub attribute_since_sqlradacct {
1311   my($self, $start, $end, $attrib) = @_;
1312
1313   my $sum = 0;
1314
1315   foreach my $cust_svc (
1316     grep {
1317       my $part_svc = $_->part_svc;
1318       $part_svc->svcdb eq 'svc_acct'
1319         && scalar($part_svc->part_export('sqlradius'));
1320     } $self->cust_svc
1321   ) {
1322     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1323   }
1324
1325   $sum;
1326
1327 }
1328
1329 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1330
1331 Transfers as many services as possible from this package to another package.
1332
1333 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1334 object.  The destination package must already exist.
1335
1336 Services are moved only if the destination allows services with the correct
1337 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
1338 this option with caution!  No provision is made for export differences
1339 between the old and new service definitions.  Probably only should be used
1340 when your exports for all service definitions of a given svcdb are identical.
1341 (attempt a transfer without it first, to move all possible svcpart-matching
1342 services)
1343
1344 Any services that can't be moved remain in the original package.
1345
1346 Returns an error, if there is one; otherwise, returns the number of services 
1347 that couldn't be moved.
1348
1349 =cut
1350
1351 sub transfer {
1352   my ($self, $dest_pkgnum, %opt) = @_;
1353
1354   my $remaining = 0;
1355   my $dest;
1356   my %target;
1357
1358   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1359     $dest = $dest_pkgnum;
1360     $dest_pkgnum = $dest->pkgnum;
1361   } else {
1362     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1363   }
1364
1365   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1366
1367   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1368     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1369   }
1370
1371   foreach my $cust_svc ($dest->cust_svc) {
1372     $target{$cust_svc->svcpart}--;
1373   }
1374
1375   my %svcpart2svcparts = ();
1376   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1377     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1378     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1379       next if exists $svcpart2svcparts{$svcpart};
1380       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1381       $svcpart2svcparts{$svcpart} = [
1382         map  { $_->[0] }
1383         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
1384         map {
1385               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1386                                                    'svcpart' => $_          } );
1387               [ $_,
1388                 $pkg_svc ? $pkg_svc->primary_svc : '',
1389                 $pkg_svc ? $pkg_svc->quantity : 0,
1390               ];
1391             }
1392
1393         grep { $_ != $svcpart }
1394         map  { $_->svcpart }
1395         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1396       ];
1397       warn "alternates for svcpart $svcpart: ".
1398            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1399         if $DEBUG;
1400     }
1401   }
1402
1403   foreach my $cust_svc ($self->cust_svc) {
1404     if($target{$cust_svc->svcpart} > 0) {
1405       $target{$cust_svc->svcpart}--;
1406       my $new = new FS::cust_svc { $cust_svc->hash };
1407       $new->pkgnum($dest_pkgnum);
1408       my $error = $new->replace($cust_svc);
1409       return $error if $error;
1410     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1411       if ( $DEBUG ) {
1412         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1413         warn "alternates to consider: ".
1414              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1415       }
1416       my @alternate = grep {
1417                              warn "considering alternate svcpart $_: ".
1418                                   "$target{$_} available in new package\n"
1419                                if $DEBUG;
1420                              $target{$_} > 0;
1421                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
1422       if ( @alternate ) {
1423         warn "alternate(s) found\n" if $DEBUG;
1424         my $change_svcpart = $alternate[0];
1425         $target{$change_svcpart}--;
1426         my $new = new FS::cust_svc { $cust_svc->hash };
1427         $new->svcpart($change_svcpart);
1428         $new->pkgnum($dest_pkgnum);
1429         my $error = $new->replace($cust_svc);
1430         return $error if $error;
1431       } else {
1432         $remaining++;
1433       }
1434     } else {
1435       $remaining++
1436     }
1437   }
1438   return $remaining;
1439 }
1440
1441 =item reexport
1442
1443 This method is deprecated.  See the I<depend_jobnum> option to the insert and
1444 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1445
1446 =cut
1447
1448 sub reexport {
1449   my $self = shift;
1450
1451   local $SIG{HUP} = 'IGNORE';
1452   local $SIG{INT} = 'IGNORE';
1453   local $SIG{QUIT} = 'IGNORE';
1454   local $SIG{TERM} = 'IGNORE';
1455   local $SIG{TSTP} = 'IGNORE';
1456   local $SIG{PIPE} = 'IGNORE';
1457
1458   my $oldAutoCommit = $FS::UID::AutoCommit;
1459   local $FS::UID::AutoCommit = 0;
1460   my $dbh = dbh;
1461
1462   foreach my $cust_svc ( $self->cust_svc ) {
1463     #false laziness w/svc_Common::insert
1464     my $svc_x = $cust_svc->svc_x;
1465     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1466       my $error = $part_export->export_insert($svc_x);
1467       if ( $error ) {
1468         $dbh->rollback if $oldAutoCommit;
1469         return $error;
1470       }
1471     }
1472   }
1473
1474   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1475   '';
1476
1477 }
1478
1479 =back
1480
1481 =head1 CLASS METHODS
1482
1483 =over 4
1484
1485 =item recurring_sql
1486
1487 Returns an SQL expression identifying recurring packages.
1488
1489 =cut
1490
1491 sub recurring_sql { "
1492   '0' != ( select freq from part_pkg
1493              where cust_pkg.pkgpart = part_pkg.pkgpart )
1494 "; }
1495
1496 =item onetime_sql
1497
1498 Returns an SQL expression identifying one-time packages.
1499
1500 =cut
1501
1502 sub onetime_sql { "
1503   '0' = ( select freq from part_pkg
1504             where cust_pkg.pkgpart = part_pkg.pkgpart )
1505 "; }
1506
1507 =item active_sql
1508
1509 Returns an SQL expression identifying active packages.
1510
1511 =cut
1512
1513 sub active_sql { "
1514   ". $_[0]->recurring_sql(). "
1515   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1516   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
1517 "; }
1518
1519 =item inactive_sql
1520
1521 Returns an SQL expression identifying inactive packages (one-time packages
1522 that are otherwise unsuspended/uncancelled).
1523
1524 =cut
1525
1526 sub inactive_sql { "
1527   ". $_[0]->onetime_sql(). "
1528   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1529   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
1530 "; }
1531
1532 =item susp_sql
1533 =item suspended_sql
1534
1535 Returns an SQL expression identifying suspended packages.
1536
1537 =cut
1538
1539 sub suspended_sql { susp_sql(@_); }
1540 sub susp_sql {
1541   #$_[0]->recurring_sql(). ' AND '.
1542   "
1543         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
1544     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
1545   ";
1546 }
1547
1548 =item cancel_sql
1549 =item cancelled_sql
1550
1551 Returns an SQL exprression identifying cancelled packages.
1552
1553 =cut
1554
1555 sub cancelled_sql { cancel_sql(@_); }
1556 sub cancel_sql { 
1557   #$_[0]->recurring_sql(). ' AND '.
1558   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1559 }
1560
1561 =item search_sql HREF
1562
1563 Returns a qsearch hash expression to search for parameters specified in HREF.
1564 Valid parameters are
1565
1566 =over 4
1567 =item agentnum
1568 =item magic - /^(active|inactive|suspended|cancell?ed)$/
1569 =item status - /^(active|inactive|suspended|one-time charge|inactive|cancell?ed)$/
1570 =item classnum
1571 =item pkgpart - list specified how?
1572 =item setup     - arrayref of beginning and ending epoch date
1573 =item last_bill - arrayref of beginning and ending epoch date
1574 =item bill      - arrayref of beginning and ending epoch date
1575 =item adjourn   - arrayref of beginning and ending epoch date
1576 =item susp      - arrayref of beginning and ending epoch date
1577 =item expire    - arrayref of beginning and ending epoch date
1578 =item cancel    - arrayref of beginning and ending epoch date
1579 =item query - /^(pkgnum/APKG_pkgnum)$/
1580 =item cust_fields - a value suited to passing to FS::UI::Web::cust_header
1581 =item CurrentUser - specifies the user for agent virtualization
1582 =back
1583
1584 =cut
1585
1586 sub search_sql { 
1587   my ($class, $params) = @_;
1588   my @where = ();
1589
1590   ##
1591   # parse agent
1592   ##
1593
1594   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
1595     push @where,
1596       "cust_main.agentnum = $1";
1597   }
1598
1599   ##
1600   # parse status
1601   ##
1602
1603   if (    $params->{'magic'}  eq 'active'
1604        || $params->{'status'} eq 'active' ) {
1605
1606     push @where, FS::cust_pkg->active_sql();
1607
1608   } elsif (    $params->{'magic'}  eq 'inactive'
1609             || $params->{'status'} eq 'inactive' ) {
1610
1611     push @where, FS::cust_pkg->inactive_sql();
1612
1613   } elsif (    $params->{'magic'}  eq 'suspended'
1614             || $params->{'status'} eq 'suspended'  ) {
1615
1616     push @where, FS::cust_pkg->suspended_sql();
1617
1618   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
1619             || $params->{'status'} =~ /^cancell?ed$/ ) {
1620
1621     push @where, FS::cust_pkg->cancelled_sql();
1622
1623   } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
1624
1625     push @where, FS::cust_pkg->inactive_sql();
1626
1627   }
1628
1629   ###
1630   # parse package class
1631   ###
1632
1633   #false lazinessish w/graph/cust_bill_pkg.cgi
1634   my $classnum = 0;
1635   my @pkg_class = ();
1636   if ( exists($params->{'classnum'})
1637        && $params->{'classnum'} =~ /^(\d*)$/
1638      )
1639   {
1640     $classnum = $1;
1641     if ( $classnum ) { #a specific class
1642       push @where, "classnum = $classnum";
1643
1644       #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
1645       #die "classnum $classnum not found!" unless $pkg_class[0];
1646       #$title .= $pkg_class[0]->classname.' ';
1647
1648     } elsif ( $classnum eq '' ) { #the empty class
1649
1650       push @where, "classnum IS NULL";
1651       #$title .= 'Empty class ';
1652       #@pkg_class = ( '(empty class)' );
1653     } elsif ( $classnum eq '0' ) {
1654       #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
1655       #push @pkg_class, '(empty class)';
1656     } else {
1657       die "illegal classnum";
1658     }
1659   }
1660   #eslaf
1661
1662   ###
1663   # parse part_pkg
1664   ###
1665
1666   my $pkgpart = join (' OR pkgpart=',
1667                       grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
1668   push @where,  '(pkgpart=' . $pkgpart . ')' if $pkgpart;
1669
1670   ###
1671   # parse dates
1672   ###
1673
1674   my $orderby = '';
1675
1676   #false laziness w/report_cust_pkg.html
1677   my %disable = (
1678     'all'             => {},
1679     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
1680     'active'          => { 'susp'=>1, 'cancel'=>1 },
1681     'suspended'       => { 'cancel' => 1 },
1682     'cancelled'       => {},
1683     ''                => {},
1684   );
1685
1686   foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
1687
1688     next unless exists($params->{$field});
1689
1690     my($beginning, $ending) = @{$params->{$field}};
1691
1692     next if $beginning == 0 && $ending == 4294967295;
1693
1694     push @where,
1695       "cust_pkg.$field IS NOT NULL",
1696       "cust_pkg.$field >= $beginning",
1697       "cust_pkg.$field <= $ending";
1698
1699     $orderby ||= "ORDER BY cust_pkg.$field";
1700
1701   }
1702
1703   $orderby ||= 'ORDER BY bill';
1704
1705   ###
1706   # parse magic, legacy, etc.
1707   ###
1708
1709   if ( $params->{'magic'} &&
1710        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
1711   ) {
1712
1713     $orderby = 'ORDER BY pkgnum';
1714
1715     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
1716       push @where, "pkgpart = $1";
1717     }
1718
1719   } elsif ( $params->{'query'} eq 'pkgnum' ) {
1720
1721     $orderby = 'ORDER BY pkgnum';
1722
1723   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
1724
1725     $orderby = 'ORDER BY pkgnum';
1726
1727     push @where, '0 < (
1728       SELECT count(*) FROM pkg_svc
1729        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
1730          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
1731                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
1732                                      AND cust_svc.svcpart = pkg_svc.svcpart
1733                                 )
1734     )';
1735   
1736   }
1737
1738   ##
1739   # setup queries, links, subs, etc. for the search
1740   ##
1741
1742   # here is the agent virtualization
1743   if ($params->{CurrentUser}) {
1744     my $access_user =
1745       qsearchs('access_user', { username => $params->{CurrentUser} });
1746
1747     if ($access_user) {
1748       push @where, $access_user->agentnums_sql('table'=>'cust_main');
1749     }else{
1750       push @where, "1=0";
1751     }
1752   }else{
1753     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
1754   }
1755
1756   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
1757
1758   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
1759                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
1760                   'LEFT JOIN pkg_class USING ( classnum ) ';
1761
1762   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
1763
1764   my $sql_query = {
1765     'table'       => 'cust_pkg',
1766     'hashref'     => {},
1767     'select'      => join(', ',
1768                                 'cust_pkg.*',
1769                                 ( map "part_pkg.$_", qw( pkg freq ) ),
1770                                 'pkg_class.classname',
1771                                 'cust_main.custnum as cust_main_custnum',
1772                                 FS::UI::Web::cust_sql_fields(
1773                                   $params->{'cust_fields'}
1774                                 ),
1775                      ),
1776     'extra_sql'   => "$extra_sql $orderby",
1777     'addl_from'   => $addl_from,
1778     'count_query' => $count_query,
1779   };
1780
1781 }
1782
1783 =head1 SUBROUTINES
1784
1785 =over 4
1786
1787 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
1788
1789 CUSTNUM is a customer (see L<FS::cust_main>)
1790
1791 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1792 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
1793 permitted.
1794
1795 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1796 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
1797 new billing items.  An error is returned if this is not possible (see
1798 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
1799 parameter.
1800
1801 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1802 newly-created cust_pkg objects.
1803
1804 REFNUM, if specified, will specify the FS::pkg_referral record to be created
1805 and inserted.  Multiple FS::pkg_referral records can be created by
1806 setting I<refnum> to an array reference of refnums or a hash reference with
1807 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
1808 record will be created corresponding to cust_main.refnum.
1809
1810 =cut
1811
1812 sub order {
1813   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1814
1815   my $conf = new FS::Conf;
1816
1817   # Transactionize this whole mess
1818   local $SIG{HUP} = 'IGNORE';
1819   local $SIG{INT} = 'IGNORE'; 
1820   local $SIG{QUIT} = 'IGNORE';
1821   local $SIG{TERM} = 'IGNORE';
1822   local $SIG{TSTP} = 'IGNORE'; 
1823   local $SIG{PIPE} = 'IGNORE'; 
1824
1825   my $oldAutoCommit = $FS::UID::AutoCommit;
1826   local $FS::UID::AutoCommit = 0;
1827   my $dbh = dbh;
1828
1829   my $error;
1830   my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1831   return "Customer not found: $custnum" unless $cust_main;
1832
1833   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1834                          @$remove_pkgnum;
1835
1836   my $change = scalar(@old_cust_pkg) != 0;
1837
1838   my %hash = (); 
1839   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
1840
1841     my $time = time;
1842
1843     #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1844     
1845     #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
1846     $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
1847
1848     $hash{'change_date'} = $time;
1849     $hash{"change_$_"}  = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
1850   }
1851
1852   # Create the new packages.
1853   foreach my $pkgpart (@$pkgparts) {
1854     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1855                                       pkgpart => $pkgpart,
1856                                       refnum  => $refnum,
1857                                       %hash,
1858                                     };
1859     $error = $cust_pkg->insert( 'change' => $change );
1860     if ($error) {
1861       $dbh->rollback if $oldAutoCommit;
1862       return $error;
1863     }
1864     push @$return_cust_pkg, $cust_pkg;
1865   }
1866   # $return_cust_pkg now contains refs to all of the newly 
1867   # created packages.
1868
1869   # Transfer services and cancel old packages.
1870   foreach my $old_pkg (@old_cust_pkg) {
1871
1872     foreach my $new_pkg (@$return_cust_pkg) {
1873       $error = $old_pkg->transfer($new_pkg);
1874       if ($error and $error == 0) {
1875         # $old_pkg->transfer failed.
1876         $dbh->rollback if $oldAutoCommit;
1877         return $error;
1878       }
1879     }
1880
1881     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1882       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1883       foreach my $new_pkg (@$return_cust_pkg) {
1884         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
1885         if ($error and $error == 0) {
1886           # $old_pkg->transfer failed.
1887         $dbh->rollback if $oldAutoCommit;
1888         return $error;
1889         }
1890       }
1891     }
1892
1893     if ($error > 0) {
1894       # Transfers were successful, but we went through all of the 
1895       # new packages and still had services left on the old package.
1896       # We can't cancel the package under the circumstances, so abort.
1897       $dbh->rollback if $oldAutoCommit;
1898       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
1899     }
1900     $error = $old_pkg->cancel( quiet=>1 );
1901     if ($error) {
1902       $dbh->rollback;
1903       return $error;
1904     }
1905   }
1906   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1907   '';
1908 }
1909
1910 =item insert_reason
1911
1912 Associates this package with a (suspension or cancellation) reason (see
1913 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
1914 L<FS::reason>).
1915
1916 Available options are:
1917
1918 =over 4
1919
1920 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
1921
1922 =item date
1923
1924 =back
1925
1926 If there is an error, returns the error, otherwise returns false.
1927
1928 =cut
1929
1930 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
1931
1932 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1933 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
1934 permitted.
1935
1936 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
1937 replace.  The services (see L<FS::cust_svc>) are moved to the
1938 new billing items.  An error is returned if this is not possible (see
1939 L<FS::pkg_svc>).
1940
1941 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1942 newly-created cust_pkg objects.
1943
1944 =cut
1945
1946 sub bulk_change {
1947   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1948
1949   # Transactionize this whole mess
1950   local $SIG{HUP} = 'IGNORE';
1951   local $SIG{INT} = 'IGNORE'; 
1952   local $SIG{QUIT} = 'IGNORE';
1953   local $SIG{TERM} = 'IGNORE';
1954   local $SIG{TSTP} = 'IGNORE'; 
1955   local $SIG{PIPE} = 'IGNORE'; 
1956
1957   my $oldAutoCommit = $FS::UID::AutoCommit;
1958   local $FS::UID::AutoCommit = 0;
1959   my $dbh = dbh;
1960
1961   my @errors;
1962   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1963                          @$remove_pkgnum;
1964
1965   while(scalar(@old_cust_pkg)) {
1966     my @return = ();
1967     my $custnum = $old_cust_pkg[0]->custnum;
1968     my (@remove) = map { $_->pkgnum }
1969                    grep { $_->custnum == $custnum } @old_cust_pkg;
1970     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
1971
1972     my $error = order $custnum, $pkgparts, \@remove, \@return;
1973
1974     push @errors, $error
1975       if $error;
1976     push @$return_cust_pkg, @return;
1977   }
1978
1979   if (scalar(@errors)) {
1980     $dbh->rollback if $oldAutoCommit;
1981     return join(' / ', @errors);
1982   }
1983
1984   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1985   '';
1986 }
1987
1988 sub insert_reason {
1989   my ($self, %options) = @_;
1990
1991   my $otaker = $FS::CurrentUser::CurrentUser->username;
1992
1993   my $reasonnum;
1994   if ( $options{'reason'} =~ /^(\d+)$/ ) {
1995
1996     $reasonnum = $1;
1997
1998   } elsif ( ref($options{'reason'}) ) {
1999   
2000     return 'Enter a new reason (or select an existing one)'
2001       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2002
2003     my $reason = new FS::reason({
2004       'reason_type' => $options{'reason'}->{'typenum'},
2005       'reason'      => $options{'reason'}->{'reason'},
2006     });
2007     my $error = $reason->insert;
2008     return $error if $error;
2009
2010     $reasonnum = $reason->reasonnum;
2011
2012   } else {
2013     return "Unparsable reason: ". $options{'reason'};
2014   }
2015
2016   my $cust_pkg_reason =
2017     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2018                               'reasonnum' => $reasonnum, 
2019                               'otaker'    => $otaker,
2020                               'date'      => $options{'date'}
2021                                                ? $options{'date'}
2022                                                : time,
2023                             });
2024
2025   $cust_pkg_reason->insert;
2026 }
2027
2028 =item set_usage USAGE_VALUE_HASHREF 
2029
2030 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2031 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2032 upbytes, downbytes, and totalbytes are appropriate keys.
2033
2034 All svc_accts which are part of this package have their values reset.
2035
2036 =cut
2037
2038 sub set_usage {
2039   my ($self, $valueref) = @_;
2040
2041   foreach my $cust_svc ($self->cust_svc){
2042     my $svc_x = $cust_svc->svc_x;
2043     $svc_x->set_usage($valueref)
2044       if $svc_x->can("set_usage");
2045   }
2046 }
2047
2048 =item recharge USAGE_VALUE_HASHREF 
2049
2050 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2051 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2052 upbytes, downbytes, and totalbytes are appropriate keys.
2053
2054 All svc_accts which are part of this package have their values incremented.
2055
2056 =cut
2057
2058 sub recharge {
2059   my ($self, $valueref) = @_;
2060
2061   foreach my $cust_svc ($self->cust_svc){
2062     my $svc_x = $cust_svc->svc_x;
2063     $svc_x->recharge($valueref)
2064       if $svc_x->can("recharge");
2065   }
2066 }
2067
2068 =back
2069
2070 =head1 BUGS
2071
2072 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
2073
2074 In sub order, the @pkgparts array (passed by reference) is clobbered.
2075
2076 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
2077 method to pass dates to the recur_prog expression, it should do so.
2078
2079 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2080 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2081 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2082 configuration values.  Probably need a subroutine which decides what to do
2083 based on whether or not we've fetched the user yet, rather than a hash.  See
2084 FS::UID and the TODO.
2085
2086 Now that things are transactional should the check in the insert method be
2087 moved to check ?
2088
2089 =head1 SEE ALSO
2090
2091 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2092 L<FS::pkg_svc>, schema.html from the base documentation
2093
2094 =cut
2095
2096 1;
2097