This commit was generated by cvs2svn to compensate for changes in r4888,
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use vars qw(@ISA $disable_agentcheck @SVCDB_CANCEL_SEQ $DEBUG);
5 use Tie::IxHash;
6 use FS::UID qw( getotaker dbh );
7 use FS::Misc qw( send_email );
8 use FS::Record qw( qsearch qsearchs );
9 use FS::cust_main_Mixin;
10 use FS::cust_svc;
11 use FS::part_pkg;
12 use FS::cust_main;
13 use FS::type_pkgs;
14 use FS::pkg_svc;
15 use FS::cust_bill_pkg;
16 use FS::h_cust_svc;
17 use FS::reg_code;
18
19 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
20 # setup }
21 # because they load configuration by setting FS::UID::callback (see TODO)
22 use FS::svc_acct;
23 use FS::svc_domain;
24 use FS::svc_www;
25 use FS::svc_forward;
26
27 # for sending cancel emails in sub cancel
28 use FS::Conf;
29
30 @ISA = qw( FS::cust_main_Mixin FS::Record );
31
32 $DEBUG = 0;
33
34 $disable_agentcheck = 0;
35
36 # The order in which to unprovision services.
37 @SVCDB_CANCEL_SEQ = qw( svc_external
38                         svc_www
39                         svc_forward 
40                         svc_acct 
41                         svc_domain 
42                         svc_broadband );
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 susp - date
119
120 =item expire - date
121
122 =item cancel - date
123
124 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
125
126 =item manual_flag - If this field is set to 1, disables the automatic
127 unsuspension of this package when using the B<unsuspendauto> config file.
128
129 =back
130
131 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
132 see L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for
133 conversion functions.
134
135 =head1 METHODS
136
137 =over 4
138
139 =item new HASHREF
140
141 Create a new billing item.  To add the item to the database, see L<"insert">.
142
143 =cut
144
145 sub table { 'cust_pkg'; }
146 sub cust_linked { $_[0]->cust_main_custnum; } 
147 sub cust_unlinked_msg {
148   my $self = shift;
149   "WARNING: can't find cust_main.custnum ". $self->custnum.
150   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
151 }
152
153 =item insert [ OPTION => VALUE ... ]
154
155 Adds this billing item to the database ("Orders" the item).  If there is an
156 error, returns the error, otherwise returns false.
157
158 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
159 will be used to look up the package definition and agent restrictions will be
160 ignored.
161
162 The following options are available: I<change>
163
164 I<change>, if set true, supresses any referral credit to a referring customer.
165
166 =cut
167
168 sub insert {
169   my( $self, %options ) = @_;
170
171   local $SIG{HUP} = 'IGNORE';
172   local $SIG{INT} = 'IGNORE';
173   local $SIG{QUIT} = 'IGNORE';
174   local $SIG{TERM} = 'IGNORE';
175   local $SIG{TSTP} = 'IGNORE';
176   local $SIG{PIPE} = 'IGNORE';
177
178   my $oldAutoCommit = $FS::UID::AutoCommit;
179   local $FS::UID::AutoCommit = 0;
180   my $dbh = dbh;
181
182   my $error = $self->SUPER::insert;
183   if ( $error ) {
184     $dbh->rollback if $oldAutoCommit;
185     return $error;
186   }
187
188   #if ( $self->reg_code ) {
189   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
190   #  $error = $reg_code->delete;
191   #  if ( $error ) {
192   #    $dbh->rollback if $oldAutoCommit;
193   #    return $error;
194   #  }
195   #}
196
197   my $conf = new FS::Conf;
198   my $cust_main = $self->cust_main;
199   my $part_pkg = $self->part_pkg;
200   if ( $conf->exists('referral_credit')
201        && $cust_main->referral_custnum
202        && ! $options{'change'}
203        && $part_pkg->freq !~ /^0\D?$/
204      )
205   {
206     my $referring_cust_main = $cust_main->referring_cust_main;
207     if ( $referring_cust_main->status ne 'cancelled' ) {
208       my $error;
209       if ( $part_pkg->freq !~ /^\d+$/ ) {
210         warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
211              ' for package '. $self->pkgnum.
212              ' ( customer '. $self->custnum. ')'.
213              ' - One-time referral credits not (yet) available for '.
214              ' packages with '. $part_pkg->freq_pretty. ' frequency';
215       } else {
216
217         my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
218         my $error =
219           $referring_cust_main->credit( $amount,
220                                         'Referral credit for '. $cust_main->name
221                                       );
222         if ( $error ) {
223           $dbh->rollback if $oldAutoCommit;
224           return "Error crediting customer ". $cust_main->referral_custnum.
225                " for referral: $error";
226         }
227
228       }
229
230     }
231   }
232
233   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
234   '';
235
236 }
237
238 =item delete
239
240 This method now works but you probably shouldn't use it.
241
242 You don't want to delete billing items, because there would then be no record
243 the customer ever purchased the item.  Instead, see the cancel method.
244
245 =cut
246
247 #sub delete {
248 #  return "Can't delete cust_pkg records!";
249 #}
250
251 =item replace OLD_RECORD
252
253 Replaces the OLD_RECORD with this one in the database.  If there is an error,
254 returns the error, otherwise returns false.
255
256 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
257
258 Changing pkgpart may have disasterous effects.  See the order subroutine.
259
260 setup and bill are normally updated by calling the bill method of a customer
261 object (see L<FS::cust_main>).
262
263 suspend is normally updated by the suspend and unsuspend methods.
264
265 cancel is normally updated by the cancel method (and also the order subroutine
266 in some cases).
267
268 Calls 
269
270 =cut
271
272 sub replace {
273   my( $new, $old ) = ( shift, shift );
274
275   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
276   return "Can't change otaker!" if $old->otaker ne $new->otaker;
277
278   #allow this *sigh*
279   #return "Can't change setup once it exists!"
280   #  if $old->getfield('setup') &&
281   #     $old->getfield('setup') != $new->getfield('setup');
282
283   #some logic for bill, susp, cancel?
284
285   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
286
287   local $SIG{HUP} = 'IGNORE';
288   local $SIG{INT} = 'IGNORE';
289   local $SIG{QUIT} = 'IGNORE';
290   local $SIG{TERM} = 'IGNORE';
291   local $SIG{TSTP} = 'IGNORE';
292   local $SIG{PIPE} = 'IGNORE';
293
294   my $oldAutoCommit = $FS::UID::AutoCommit;
295   local $FS::UID::AutoCommit = 0;
296   my $dbh = dbh;
297
298   #save off and freeze RADIUS attributes for any associated svc_acct records
299   my @svc_acct = ();
300   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
301
302                 #also check for specific exports?
303                 # to avoid spurious modify export events
304     @svc_acct = map  { $_->svc_x }
305                 grep { $_->part_svc->svcdb eq 'svc_acct' }
306                      $old->cust_svc;
307
308     $_->snapshot foreach @svc_acct;
309
310   }
311
312   my $error = $new->SUPER::replace($old);
313   if ( $error ) {
314     $dbh->rollback if $oldAutoCommit;
315     return $error;
316   }
317
318   #for prepaid packages,
319   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
320   foreach my $old_svc_acct ( @svc_acct ) {
321     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
322     my $s_error = $new_svc_acct->replace($old_svc_acct);
323     if ( $s_error ) {
324       $dbh->rollback if $oldAutoCommit;
325       return $s_error;
326     }
327   }
328
329   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
330   '';
331
332 }
333
334 =item check
335
336 Checks all fields to make sure this is a valid billing item.  If there is an
337 error, returns the error, otherwise returns false.  Called by the insert and
338 replace methods.
339
340 =cut
341
342 sub check {
343   my $self = shift;
344
345   my $error = 
346     $self->ut_numbern('pkgnum')
347     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
348     || $self->ut_numbern('pkgpart')
349     || $self->ut_numbern('setup')
350     || $self->ut_numbern('bill')
351     || $self->ut_numbern('susp')
352     || $self->ut_numbern('cancel')
353   ;
354   return $error if $error;
355
356   if ( $self->reg_code ) {
357
358     unless ( grep { $self->pkgpart == $_->pkgpart }
359              map  { $_->reg_code_pkg }
360              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
361                                      'agentnum' => $self->cust_main->agentnum })
362            ) {
363       return "Unknown registration code";
364     }
365
366   } elsif ( $self->promo_code ) {
367
368     my $promo_part_pkg =
369       qsearchs('part_pkg', {
370         'pkgpart'    => $self->pkgpart,
371         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
372       } );
373     return 'Unknown promotional code' unless $promo_part_pkg;
374
375   } else { 
376
377     unless ( $disable_agentcheck ) {
378       my $agent =
379         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
380       my $pkgpart_href = $agent->pkgpart_hashref;
381       return "agent ". $agent->agentnum.
382              " can't purchase pkgpart ". $self->pkgpart
383         unless $pkgpart_href->{ $self->pkgpart };
384     }
385
386     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
387     return $error if $error;
388
389   }
390
391   $self->otaker(getotaker) unless $self->otaker;
392   $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
393   $self->otaker($1);
394
395   if ( $self->dbdef_table->column('manual_flag') ) {
396     $self->manual_flag('') if $self->manual_flag eq ' ';
397     $self->manual_flag =~ /^([01]?)$/
398       or return "Illegal manual_flag ". $self->manual_flag;
399     $self->manual_flag($1);
400   }
401
402   $self->SUPER::check;
403 }
404
405 =item cancel [ OPTION => VALUE ... ]
406
407 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
408 in this package, then cancels the package itself (sets the cancel field to
409 now).
410
411 Available options are: I<quiet>
412
413 I<quiet> can be set true to supress email cancellation notices.
414
415 If there is an error, returns the error, otherwise returns false.
416
417 =cut
418
419 sub cancel {
420   my( $self, %options ) = @_;
421   my $error;
422
423   local $SIG{HUP} = 'IGNORE';
424   local $SIG{INT} = 'IGNORE';
425   local $SIG{QUIT} = 'IGNORE'; 
426   local $SIG{TERM} = 'IGNORE';
427   local $SIG{TSTP} = 'IGNORE';
428   local $SIG{PIPE} = 'IGNORE';
429
430   my $oldAutoCommit = $FS::UID::AutoCommit;
431   local $FS::UID::AutoCommit = 0;
432   my $dbh = dbh;
433
434   my %svc;
435   foreach my $cust_svc (
436       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
437   ) {
438     push @{ $svc{$cust_svc->part_svc->svcdb} }, $cust_svc;
439   }
440
441   foreach my $svcdb (@SVCDB_CANCEL_SEQ) {
442     foreach my $cust_svc (@{ $svc{$svcdb} }) {
443       my $error = $cust_svc->cancel;
444
445       if ( $error ) {
446         $dbh->rollback if $oldAutoCommit;
447         return "Error cancelling cust_svc: $error";
448       }
449     }
450   }
451
452   # Add a credit for remaining service
453   my $remaining_value = $self->calc_remain();
454   if ( $remaining_value > 0 ) {
455     my $error = $self->cust_main->credit(
456       $remaining_value,
457       'Credit for unused time on '. $self->part_pkg->pkg,
458     );
459     if ($error) {
460       $dbh->rollback if $oldAutoCommit;
461       return "Error crediting customer \$$remaining_value for unused time on".
462              $self->part_pkg->pkg. ": $error";
463     }                                                                          
464   }                                                                            
465
466   unless ( $self->getfield('cancel') ) {
467     my %hash = $self->hash;
468     $hash{'cancel'} = time;
469     my $new = new FS::cust_pkg ( \%hash );
470     $error = $new->replace($self);
471     if ( $error ) {
472       $dbh->rollback if $oldAutoCommit;
473       return $error;
474     }
475   }
476
477   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
478
479   my $conf = new FS::Conf;
480   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
481   if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
482     my $conf = new FS::Conf;
483     my $error = send_email(
484       'from'    => $conf->config('invoice_from'),
485       'to'      => \@invoicing_list,
486       'subject' => $conf->config('cancelsubject'),
487       'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
488     );
489     #should this do something on errors?
490   }
491
492   ''; #no errors
493
494 }
495
496 =item suspend
497
498 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
499 package, then suspends the package itself (sets the susp field to now).
500
501 If there is an error, returns the error, otherwise returns false.
502
503 =cut
504
505 sub suspend {
506   my $self = shift;
507   my $error ;
508
509   local $SIG{HUP} = 'IGNORE';
510   local $SIG{INT} = 'IGNORE';
511   local $SIG{QUIT} = 'IGNORE'; 
512   local $SIG{TERM} = 'IGNORE';
513   local $SIG{TSTP} = 'IGNORE';
514   local $SIG{PIPE} = 'IGNORE';
515
516   my $oldAutoCommit = $FS::UID::AutoCommit;
517   local $FS::UID::AutoCommit = 0;
518   my $dbh = dbh;
519
520   foreach my $cust_svc (
521     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
522   ) {
523     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
524
525     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
526       $dbh->rollback if $oldAutoCommit;
527       return "Illegal svcdb value in part_svc!";
528     };
529     my $svcdb = $1;
530     require "FS/$svcdb.pm";
531
532     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
533     if ($svc) {
534       $error = $svc->suspend;
535       if ( $error ) {
536         $dbh->rollback if $oldAutoCommit;
537         return $error;
538       }
539     }
540
541   }
542
543   unless ( $self->getfield('susp') ) {
544     my %hash = $self->hash;
545     $hash{'susp'} = time;
546     my $new = new FS::cust_pkg ( \%hash );
547     $error = $new->replace($self);
548     if ( $error ) {
549       $dbh->rollback if $oldAutoCommit;
550       return $error;
551     }
552   }
553
554   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
555
556   ''; #no errors
557 }
558
559 =item unsuspend [ OPTION => VALUE ... ]
560
561 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
562 package, then unsuspends the package itself (clears the susp field).
563
564 Available options are: I<adjust_next_bill>.
565
566 I<adjust_next_bill> can be set true to adjust the next bill date forward by
567 the amount of time the account was inactive.  This was set true by default
568 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
569 explicitly requested.  Price plans for which this makes sense (anniversary-date
570 based than prorate or subscription) could have an option to enable this
571 behaviour?
572
573 If there is an error, returns the error, otherwise returns false.
574
575 =cut
576
577 sub unsuspend {
578   my( $self, %opt ) = @_;
579   my $error;
580
581   local $SIG{HUP} = 'IGNORE';
582   local $SIG{INT} = 'IGNORE';
583   local $SIG{QUIT} = 'IGNORE'; 
584   local $SIG{TERM} = 'IGNORE';
585   local $SIG{TSTP} = 'IGNORE';
586   local $SIG{PIPE} = 'IGNORE';
587
588   my $oldAutoCommit = $FS::UID::AutoCommit;
589   local $FS::UID::AutoCommit = 0;
590   my $dbh = dbh;
591
592   foreach my $cust_svc (
593     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
594   ) {
595     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
596
597     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
598       $dbh->rollback if $oldAutoCommit;
599       return "Illegal svcdb value in part_svc!";
600     };
601     my $svcdb = $1;
602     require "FS/$svcdb.pm";
603
604     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
605     if ($svc) {
606       $error = $svc->unsuspend;
607       if ( $error ) {
608         $dbh->rollback if $oldAutoCommit;
609         return $error;
610       }
611     }
612
613   }
614
615   unless ( ! $self->getfield('susp') ) {
616     my %hash = $self->hash;
617     my $inactive = time - $hash{'susp'};
618
619     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
620       if $opt{'adjust_next_bill'}
621       && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
622
623     $hash{'susp'} = '';
624     my $new = new FS::cust_pkg ( \%hash );
625     $error = $new->replace($self);
626     if ( $error ) {
627       $dbh->rollback if $oldAutoCommit;
628       return $error;
629     }
630   }
631
632   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
633
634   ''; #no errors
635 }
636
637 =item last_bill
638
639 Returns the last bill date, or if there is no last bill date, the setup date.
640 Useful for billing metered services.
641
642 =cut
643
644 sub last_bill {
645   my $self = shift;
646   if ( $self->dbdef_table->column('last_bill') ) {
647     return $self->setfield('last_bill', $_[0]) if @_;
648     return $self->getfield('last_bill') if $self->getfield('last_bill');
649   }    
650   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
651                                                   'edate'  => $self->bill,  } );
652   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
653 }
654
655 =item part_pkg
656
657 Returns the definition for this billing item, as an FS::part_pkg object (see
658 L<FS::part_pkg>).
659
660 =cut
661
662 sub part_pkg {
663   my $self = shift;
664   #exists( $self->{'_pkgpart'} )
665   $self->{'_pkgpart'}
666     ? $self->{'_pkgpart'}
667     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
668 }
669
670 =item calc_setup
671
672 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
673 item.
674
675 =cut
676
677 sub calc_setup {
678   my $self = shift;
679   $self->part_pkg->calc_setup($self, @_);
680 }
681
682 =item calc_recur
683
684 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
685 item.
686
687 =cut
688
689 sub calc_recur {
690   my $self = shift;
691   $self->part_pkg->calc_recur($self, @_);
692 }
693
694 =item calc_remain
695
696 Calls the I<calc_remain> of the FS::part_pkg object associated with this
697 billing item.
698
699 =cut
700
701 sub calc_remain {
702   my $self = shift;
703   $self->part_pkg->calc_remain($self, @_);
704 }
705
706 =item calc_cancel
707
708 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
709 billing item.
710
711 =cut
712
713 sub calc_cancel {
714   my $self = shift;
715   $self->part_pkg->calc_cancel($self, @_);
716 }
717
718 =item cust_svc [ SVCPART ]
719
720 Returns the services for this package, as FS::cust_svc objects (see
721 L<FS::cust_svc>).  If a svcpart is specified, return only the matching
722 services.
723
724 =cut
725
726 sub cust_svc {
727   my $self = shift;
728
729   if ( @_ ) {
730     return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
731                                   'svcpart' => shift,          } );
732   }
733
734   #if ( $self->{'_svcnum'} ) {
735   #  values %{ $self->{'_svcnum'}->cache };
736   #} else {
737     $self->_sort_cust_svc(
738       [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
739     );
740   #}
741
742 }
743
744 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] 
745
746 Returns historical services for this package created before END TIMESTAMP and
747 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
748 (see L<FS::h_cust_svc>).
749
750 =cut
751
752 sub h_cust_svc {
753   my $self = shift;
754
755   $self->_sort_cust_svc(
756     [ qsearch( 'h_cust_svc',
757                { 'pkgnum' => $self->pkgnum, },
758                FS::h_cust_svc->sql_h_search(@_),
759              )
760     ]
761   );
762 }
763
764 sub _sort_cust_svc {
765   my( $self, $arrayref ) = @_;
766
767   map  { $_->[0] }
768   sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
769   map {
770         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
771                                              'svcpart' => $_->svcpart     } );
772         [ $_,
773           $pkg_svc ? $pkg_svc->primary_svc : '',
774           $pkg_svc ? $pkg_svc->quantity : 0,
775         ];
776       }
777   @$arrayref;
778
779 }
780
781 =item num_cust_svc [ SVCPART ]
782
783 Returns the number of provisioned services for this package.  If a svcpart is
784 specified, counts only the matching services.
785
786 =cut
787
788 sub num_cust_svc {
789   my $self = shift;
790   my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
791   $sql .= ' AND svcpart = ?' if @_;
792   my $sth = dbh->prepare($sql) or die dbh->errstr;
793   $sth->execute($self->pkgnum, @_) or die $sth->errstr;
794   $sth->fetchrow_arrayref->[0];
795 }
796
797 =item available_part_svc 
798
799 Returns a list FS::part_svc objects representing services included in this
800 package but not yet provisioned.  Each FS::part_svc object also has an extra
801 field, I<num_avail>, which specifies the number of available services.
802
803 =cut
804
805 sub available_part_svc {
806   my $self = shift;
807   grep { $_->num_avail > 0 }
808     map {
809           my $part_svc = $_->part_svc;
810           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
811             $_->quantity - $self->num_cust_svc($_->svcpart);
812           $part_svc;
813         }
814       $self->part_pkg->pkg_svc;
815 }
816
817 =item status
818
819 Returns a short status string for this package, currently:
820
821 =over 4
822
823 =item not yet billed
824
825 =item one-time charge
826
827 =item active
828
829 =item suspended
830
831 =item cancelled
832
833 =back
834
835 =cut
836
837 sub status {
838   my $self = shift;
839
840   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
841
842   return 'cancelled' if $self->get('cancel');
843   return 'suspended' if $self->susp;
844   return 'not yet billed' unless $self->setup;
845   return 'one-time charge' if $freq =~ /^(0|$)/;
846   return 'active';
847 }
848
849 =item statuses
850
851 Class method that returns the list of possible status strings for pacakges
852 (see L<the status method|/status>).  For example:
853
854   @statuses = FS::cust_pkg->statuses();
855
856 =cut
857
858 tie my %statuscolor, 'Tie::IxHash', 
859   'not yet billed'  => '000000',
860   'one-time charge' => '000000',
861   'active'          => '00CC00',
862   'suspended'       => 'FF9900',
863   'cancelled'       => 'FF0000',
864 ;
865
866 sub statuses {
867   my $self = shift; #could be class...
868   grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
869                                       # mayble split btw one-time vs. recur
870     keys %statuscolor;
871 }
872
873 =item statuscolor
874
875 Returns a hex triplet color string for this package's status.
876
877 =cut
878
879 sub statuscolor {
880   my $self = shift;
881   $statuscolor{$self->status};
882 }
883
884 =item labels
885
886 Returns a list of lists, calling the label method for all services
887 (see L<FS::cust_svc>) of this billing item.
888
889 =cut
890
891 sub labels {
892   my $self = shift;
893   map { [ $_->label ] } $self->cust_svc;
894 }
895
896 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] 
897
898 Like the labels method, but returns historical information on services that
899 were active as of END_TIMESTAMP and (optionally) not cancelled before
900 START_TIMESTAMP.
901
902 Returns a list of lists, calling the label method for all (historical) services
903 (see L<FS::h_cust_svc>) of this billing item.
904
905 =cut
906
907 sub h_labels {
908   my $self = shift;
909   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
910 }
911
912 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
913
914 Like h_labels, except returns a simple flat list, and shortens long 
915 (currently >5) lists of identical services to one line that lists the service
916 label and the number of individual services rather than individual items.
917
918 =cut
919
920 sub h_labels_short {
921   my $self = shift;
922
923   my %labels;
924   #tie %labels, 'Tie::IxHash';
925   push @{ $labels{$_->[0]} }, $_->[1]
926     foreach $self->h_labels(@_);
927   my @labels;
928   foreach my $label ( keys %labels ) {
929     my @values = @{ $labels{$label} };
930     my $num = scalar(@values);
931     if ( $num > 5 ) {
932       push @labels, "$label ($num)";
933     } else {
934       push @labels, map { "$label: $_" } @values;
935     }
936   }
937
938  @labels;
939
940 }
941
942 =item cust_main
943
944 Returns the parent customer object (see L<FS::cust_main>).
945
946 =cut
947
948 sub cust_main {
949   my $self = shift;
950   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
951 }
952
953 =item seconds_since TIMESTAMP
954
955 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
956 package have been online since TIMESTAMP, according to the session monitor.
957
958 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
959 L<Time::Local> and L<Date::Parse> for conversion functions.
960
961 =cut
962
963 sub seconds_since {
964   my($self, $since) = @_;
965   my $seconds = 0;
966
967   foreach my $cust_svc (
968     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
969   ) {
970     $seconds += $cust_svc->seconds_since($since);
971   }
972
973   $seconds;
974
975 }
976
977 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
978
979 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
980 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
981 (exclusive).
982
983 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
984 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
985 functions.
986
987
988 =cut
989
990 sub seconds_since_sqlradacct {
991   my($self, $start, $end) = @_;
992
993   my $seconds = 0;
994
995   foreach my $cust_svc (
996     grep {
997       my $part_svc = $_->part_svc;
998       $part_svc->svcdb eq 'svc_acct'
999         && scalar($part_svc->part_export('sqlradius'));
1000     } $self->cust_svc
1001   ) {
1002     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1003   }
1004
1005   $seconds;
1006
1007 }
1008
1009 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1010
1011 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1012 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1013 TIMESTAMP_END
1014 (exclusive).
1015
1016 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1017 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
1018 functions.
1019
1020 =cut
1021
1022 sub attribute_since_sqlradacct {
1023   my($self, $start, $end, $attrib) = @_;
1024
1025   my $sum = 0;
1026
1027   foreach my $cust_svc (
1028     grep {
1029       my $part_svc = $_->part_svc;
1030       $part_svc->svcdb eq 'svc_acct'
1031         && scalar($part_svc->part_export('sqlradius'));
1032     } $self->cust_svc
1033   ) {
1034     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1035   }
1036
1037   $sum;
1038
1039 }
1040
1041 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1042
1043 Transfers as many services as possible from this package to another package.
1044
1045 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1046 object.  The destination package must already exist.
1047
1048 Services are moved only if the destination allows services with the correct
1049 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
1050 this option with caution!  No provision is made for export differences
1051 between the old and new service definitions.  Probably only should be used
1052 when your exports for all service definitions of a given svcdb are identical.
1053 (attempt a transfer without it first, to move all possible svcpart-matching
1054 services)
1055
1056 Any services that can't be moved remain in the original package.
1057
1058 Returns an error, if there is one; otherwise, returns the number of services 
1059 that couldn't be moved.
1060
1061 =cut
1062
1063 sub transfer {
1064   my ($self, $dest_pkgnum, %opt) = @_;
1065
1066   my $remaining = 0;
1067   my $dest;
1068   my %target;
1069
1070   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1071     $dest = $dest_pkgnum;
1072     $dest_pkgnum = $dest->pkgnum;
1073   } else {
1074     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1075   }
1076
1077   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1078
1079   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1080     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1081   }
1082
1083   foreach my $cust_svc ($dest->cust_svc) {
1084     $target{$cust_svc->svcpart}--;
1085   }
1086
1087   my %svcpart2svcparts = ();
1088   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1089     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1090     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1091       next if exists $svcpart2svcparts{$svcpart};
1092       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1093       $svcpart2svcparts{$svcpart} = [
1094         map  { $_->[0] }
1095         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
1096         map {
1097               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1098                                                    'svcpart' => $_          } );
1099               [ $_,
1100                 $pkg_svc ? $pkg_svc->primary_svc : '',
1101                 $pkg_svc ? $pkg_svc->quantity : 0,
1102               ];
1103             }
1104
1105         grep { $_ != $svcpart }
1106         map  { $_->svcpart }
1107         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1108       ];
1109       warn "alternates for svcpart $svcpart: ".
1110            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1111         if $DEBUG;
1112     }
1113   }
1114
1115   foreach my $cust_svc ($self->cust_svc) {
1116     if($target{$cust_svc->svcpart} > 0) {
1117       $target{$cust_svc->svcpart}--;
1118       my $new = new FS::cust_svc {
1119         svcnum  => $cust_svc->svcnum,
1120         svcpart => $cust_svc->svcpart,
1121         pkgnum  => $dest_pkgnum,
1122       };
1123       my $error = $new->replace($cust_svc);
1124       return $error if $error;
1125     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1126       if ( $DEBUG ) {
1127         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1128         warn "alternates to consider: ".
1129              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1130       }
1131       my @alternate = grep {
1132                              warn "considering alternate svcpart $_: ".
1133                                   "$target{$_} available in new package\n"
1134                                if $DEBUG;
1135                              $target{$_} > 0;
1136                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
1137       if ( @alternate ) {
1138         warn "alternate(s) found\n" if $DEBUG;
1139         my $change_svcpart = $alternate[0];
1140         $target{$change_svcpart}--;
1141         my $new = new FS::cust_svc {
1142           svcnum  => $cust_svc->svcnum,
1143           svcpart => $change_svcpart,
1144           pkgnum  => $dest_pkgnum,
1145         };
1146         my $error = $new->replace($cust_svc);
1147         return $error if $error;
1148       } else {
1149         $remaining++;
1150       }
1151     } else {
1152       $remaining++
1153     }
1154   }
1155   return $remaining;
1156 }
1157
1158 =item reexport
1159
1160 This method is deprecated.  See the I<depend_jobnum> option to the insert and
1161 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1162
1163 =cut
1164
1165 sub reexport {
1166   my $self = shift;
1167
1168   local $SIG{HUP} = 'IGNORE';
1169   local $SIG{INT} = 'IGNORE';
1170   local $SIG{QUIT} = 'IGNORE';
1171   local $SIG{TERM} = 'IGNORE';
1172   local $SIG{TSTP} = 'IGNORE';
1173   local $SIG{PIPE} = 'IGNORE';
1174
1175   my $oldAutoCommit = $FS::UID::AutoCommit;
1176   local $FS::UID::AutoCommit = 0;
1177   my $dbh = dbh;
1178
1179   foreach my $cust_svc ( $self->cust_svc ) {
1180     #false laziness w/svc_Common::insert
1181     my $svc_x = $cust_svc->svc_x;
1182     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1183       my $error = $part_export->export_insert($svc_x);
1184       if ( $error ) {
1185         $dbh->rollback if $oldAutoCommit;
1186         return $error;
1187       }
1188     }
1189   }
1190
1191   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1192   '';
1193
1194 }
1195
1196 =back
1197
1198 =head1 CLASS METHODS
1199
1200 =over 4
1201
1202 =item recurring_sql
1203
1204 Returns an SQL expression identifying recurring packages.
1205
1206 =cut
1207
1208 sub recurring_sql { "
1209   '0' != ( select freq from part_pkg
1210              where cust_pkg.pkgpart = part_pkg.pkgpart )
1211 "; }
1212
1213 =item onetime_sql
1214
1215 Returns an SQL expression identifying one-time packages.
1216
1217 =cut
1218
1219 sub onetime_sql { "
1220   '0' = ( select freq from part_pkg
1221             where cust_pkg.pkgpart = part_pkg.pkgpart )
1222 "; }
1223
1224 =item active_sql
1225
1226 Returns an SQL expression identifying active packages.
1227
1228 =cut
1229
1230 sub active_sql { "
1231   ". $_[0]->recurring_sql(). "
1232   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1233   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
1234 "; }
1235
1236 =item inactive_sql
1237
1238 Returns an SQL expression identifying inactive packages (one-time packages
1239 that are otherwise unsuspended/uncancelled).
1240
1241 =cut
1242
1243 sub inactive_sql { "
1244   ". $_[0]->onetime_sql(). "
1245   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1246   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
1247 "; }
1248
1249 =item susp_sql
1250 =item suspended_sql
1251
1252 Returns an SQL expression identifying suspended packages.
1253
1254 =cut
1255
1256 sub suspended_sql { susp_sql(@_); }
1257 sub susp_sql {
1258   #$_[0]->recurring_sql(). ' AND '.
1259   "
1260         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
1261     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
1262   ";
1263 }
1264
1265 =item cancel_sql
1266 =item cancelled_sql
1267
1268 Returns an SQL exprression identifying cancelled packages.
1269
1270 =cut
1271
1272 sub cancelled_sql { cancel_sql(@_); }
1273 sub cancel_sql { 
1274   #$_[0]->recurring_sql(). ' AND '.
1275   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1276 }
1277
1278 =head1 SUBROUTINES
1279
1280 =over 4
1281
1282 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
1283
1284 CUSTNUM is a customer (see L<FS::cust_main>)
1285
1286 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1287 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
1288 permitted.
1289
1290 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1291 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
1292 new billing items.  An error is returned if this is not possible (see
1293 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
1294 parameter.
1295
1296 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1297 newly-created cust_pkg objects.
1298
1299 =cut
1300
1301 sub order {
1302   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1303
1304   my $conf = new FS::Conf;
1305
1306   # Transactionize this whole mess
1307   local $SIG{HUP} = 'IGNORE';
1308   local $SIG{INT} = 'IGNORE'; 
1309   local $SIG{QUIT} = 'IGNORE';
1310   local $SIG{TERM} = 'IGNORE';
1311   local $SIG{TSTP} = 'IGNORE'; 
1312   local $SIG{PIPE} = 'IGNORE'; 
1313
1314   my $oldAutoCommit = $FS::UID::AutoCommit;
1315   local $FS::UID::AutoCommit = 0;
1316   my $dbh = dbh;
1317
1318   my $error;
1319   my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1320   return "Customer not found: $custnum" unless $cust_main;
1321
1322   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1323                          @$remove_pkgnum;
1324
1325   my $change = scalar(@old_cust_pkg) != 0;
1326
1327   my %hash = (); 
1328   if ( scalar(@old_cust_pkg) == 1 ) {
1329     #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1330     $hash{'setup'} = time;
1331   }
1332
1333   # Create the new packages.
1334   foreach my $pkgpart (@$pkgparts) {
1335     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1336                                       pkgpart => $pkgpart,
1337                                       %hash,
1338                                     };
1339     $error = $cust_pkg->insert( 'change' => $change );
1340     if ($error) {
1341       $dbh->rollback if $oldAutoCommit;
1342       return $error;
1343     }
1344     push @$return_cust_pkg, $cust_pkg;
1345   }
1346   # $return_cust_pkg now contains refs to all of the newly 
1347   # created packages.
1348
1349   # Transfer services and cancel old packages.
1350   foreach my $old_pkg (@old_cust_pkg) {
1351
1352     foreach my $new_pkg (@$return_cust_pkg) {
1353       $error = $old_pkg->transfer($new_pkg);
1354       if ($error and $error == 0) {
1355         # $old_pkg->transfer failed.
1356         $dbh->rollback if $oldAutoCommit;
1357         return $error;
1358       }
1359     }
1360
1361     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1362       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1363       foreach my $new_pkg (@$return_cust_pkg) {
1364         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
1365         if ($error and $error == 0) {
1366           # $old_pkg->transfer failed.
1367         $dbh->rollback if $oldAutoCommit;
1368         return $error;
1369         }
1370       }
1371     }
1372
1373     if ($error > 0) {
1374       # Transfers were successful, but we went through all of the 
1375       # new packages and still had services left on the old package.
1376       # We can't cancel the package under the circumstances, so abort.
1377       $dbh->rollback if $oldAutoCommit;
1378       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
1379     }
1380     $error = $old_pkg->cancel( quiet=>1 );
1381     if ($error) {
1382       $dbh->rollback;
1383       return $error;
1384     }
1385   }
1386   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1387   '';
1388 }
1389
1390 =back
1391
1392 =head1 BUGS
1393
1394 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
1395
1396 In sub order, the @pkgparts array (passed by reference) is clobbered.
1397
1398 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
1399 method to pass dates to the recur_prog expression, it should do so.
1400
1401 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
1402 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
1403 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
1404 configuration values.  Probably need a subroutine which decides what to do
1405 based on whether or not we've fetched the user yet, rather than a hash.  See
1406 FS::UID and the TODO.
1407
1408 Now that things are transactional should the check in the insert method be
1409 moved to check ?
1410
1411 =head1 SEE ALSO
1412
1413 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
1414 L<FS::pkg_svc>, schema.html from the base documentation
1415
1416 =cut
1417
1418 1;
1419