Fixed setting of global variable that was missed in last commit
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin
3              FS::contact_Mixin FS::location_Mixin
4              FS::m2m_Common FS::option_Common );
5
6 use strict;
7 use Carp qw(cluck);
8 use Scalar::Util qw( blessed );
9 use List::Util qw(min max);
10 use Tie::IxHash;
11 use Time::Local qw( timelocal timelocal_nocheck );
12 use MIME::Entity;
13 use FS::UID qw( dbh driver_name );
14 use FS::Misc qw( send_email );
15 use FS::Record qw( qsearch qsearchs fields );
16 use FS::CurrentUser;
17 use FS::cust_svc;
18 use FS::part_pkg;
19 use FS::cust_main;
20 use FS::contact;
21 use FS::cust_location;
22 use FS::pkg_svc;
23 use FS::cust_bill_pkg;
24 use FS::cust_pkg_detail;
25 use FS::cust_pkg_usage;
26 use FS::cdr_cust_pkg_usage;
27 use FS::cust_event;
28 use FS::h_cust_svc;
29 use FS::reg_code;
30 use FS::part_svc;
31 use FS::cust_pkg_reason;
32 use FS::reason;
33 use FS::cust_pkg_discount;
34 use FS::discount;
35 use FS::UI::Web;
36 use FS::sales;
37 # for modify_charge
38 use FS::cust_credit;
39
40 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
41 # setup }
42 # because they load configuration by setting FS::UID::callback (see TODO)
43 use FS::svc_acct;
44 use FS::svc_domain;
45 use FS::svc_www;
46 use FS::svc_forward;
47
48 # for sending cancel emails in sub cancel
49 use FS::Conf;
50
51 our ($disable_agentcheck, $DEBUG, $me) = (0, 0, '[FS::cust_pkg]');
52
53 sub _cache {
54   my $self = shift;
55   my ( $hashref, $cache ) = @_;
56   #if ( $hashref->{'pkgpart'} ) {
57   if ( $hashref->{'pkg'} ) {
58     # #@{ $self->{'_pkgnum'} } = ();
59     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
60     # $self->{'_pkgpart'} = $subcache;
61     # #push @{ $self->{'_pkgnum'} },
62     #   FS::part_pkg->new_or_cached($hashref, $subcache);
63     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
64   }
65   if ( exists $hashref->{'svcnum'} ) {
66     #@{ $self->{'_pkgnum'} } = ();
67     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
68     $self->{'_svcnum'} = $subcache;
69     #push @{ $self->{'_pkgnum'} },
70     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
71   }
72 }
73
74 =head1 NAME
75
76 FS::cust_pkg - Object methods for cust_pkg objects
77
78 =head1 SYNOPSIS
79
80   use FS::cust_pkg;
81
82   $record = new FS::cust_pkg \%hash;
83   $record = new FS::cust_pkg { 'column' => 'value' };
84
85   $error = $record->insert;
86
87   $error = $new_record->replace($old_record);
88
89   $error = $record->delete;
90
91   $error = $record->check;
92
93   $error = $record->cancel;
94
95   $error = $record->suspend;
96
97   $error = $record->unsuspend;
98
99   $part_pkg = $record->part_pkg;
100
101   @labels = $record->labels;
102
103   $seconds = $record->seconds_since($timestamp);
104
105   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
106   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
107
108 =head1 DESCRIPTION
109
110 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
111 inherits from FS::Record.  The following fields are currently supported:
112
113 =over 4
114
115 =item pkgnum
116
117 Primary key (assigned automatically for new billing items)
118
119 =item custnum
120
121 Customer (see L<FS::cust_main>)
122
123 =item pkgpart
124
125 Billing item definition (see L<FS::part_pkg>)
126
127 =item locationnum
128
129 Optional link to package location (see L<FS::location>)
130
131 =item order_date
132
133 date package was ordered (also remains same on changes)
134
135 =item start_date
136
137 date
138
139 =item setup
140
141 date
142
143 =item bill
144
145 date (next bill date)
146
147 =item last_bill
148
149 last bill date
150
151 =item adjourn
152
153 date
154
155 =item susp
156
157 date
158
159 =item expire
160
161 date
162
163 =item contract_end
164
165 date
166
167 =item cancel
168
169 date
170
171 =item usernum
172
173 order taker (see L<FS::access_user>)
174
175 =item manual_flag
176
177 If this field is set to 1, disables the automatic
178 unsuspension of this package when using the B<unsuspendauto> config option.
179
180 =item quantity
181
182 If not set, defaults to 1
183
184 =item change_date
185
186 Date of change from previous package
187
188 =item change_pkgnum
189
190 Previous pkgnum
191
192 =item change_pkgpart
193
194 Previous pkgpart
195
196 =item change_locationnum
197
198 Previous locationnum
199
200 =item waive_setup
201
202 =item main_pkgnum
203
204 The pkgnum of the package that this package is supplemental to, if any.
205
206 =item pkglinknum
207
208 The package link (L<FS::part_pkg_link>) that defines this supplemental
209 package, if it is one.
210
211 =item change_to_pkgnum
212
213 The pkgnum of the package this one will be "changed to" in the future
214 (on its expiration date).
215
216 =back
217
218 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
219 are specified as UNIX timestamps; see L<perlfunc/"time">.  Also see
220 L<Time::Local> and L<Date::Parse> for conversion functions.
221
222 =head1 METHODS
223
224 =over 4
225
226 =item new HASHREF
227
228 Create a new billing item.  To add the item to the database, see L<"insert">.
229
230 =cut
231
232 sub table { 'cust_pkg'; }
233 sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
234 sub cust_unlinked_msg {
235   my $self = shift;
236   "WARNING: can't find cust_main.custnum ". $self->custnum.
237   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
238 }
239
240 =item insert [ OPTION => VALUE ... ]
241
242 Adds this billing item to the database ("Orders" the item).  If there is an
243 error, returns the error, otherwise returns false.
244
245 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
246 will be used to look up the package definition and agent restrictions will be
247 ignored.
248
249 If the additional field I<refnum> is defined, an FS::pkg_referral record will
250 be created and inserted.  Multiple FS::pkg_referral records can be created by
251 setting I<refnum> to an array reference of refnums or a hash reference with
252 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
253 record will be created corresponding to cust_main.refnum.
254
255 The following options are available:
256
257 =over 4
258
259 =item change
260
261 If set true, supresses actions that should only be taken for new package
262 orders.  (Currently this includes: intro periods when delay_setup is on.)
263
264 =item options
265
266 cust_pkg_option records will be created
267
268 =item ticket_subject
269
270 a ticket will be added to this customer with this subject
271
272 =item ticket_queue
273
274 an optional queue name for ticket additions
275
276 =item allow_pkgpart
277
278 Don't check the legality of the package definition.  This should be used
279 when performing a package change that doesn't change the pkgpart (i.e. 
280 a location change).
281
282 =back
283
284 =cut
285
286 sub insert {
287   my( $self, %options ) = @_;
288
289   my $error;
290   $error = $self->check_pkgpart unless $options{'allow_pkgpart'};
291   return $error if $error;
292
293   my $part_pkg = $self->part_pkg;
294
295   # if the package def says to start only on the first of the month:
296   if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
297     my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
298     $mon += 1 unless $mday == 1;
299     until ( $mon < 12 ) { $mon -= 12; $year++; }
300     $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
301   }
302
303   # set up any automatic expire/adjourn/contract_end timers
304   # based on the start date
305   foreach my $action ( qw(expire adjourn contract_end) ) {
306     my $months = $part_pkg->option("${action}_months",1);
307     if($months and !$self->$action) {
308       my $start = $self->start_date || $self->setup || time;
309       $self->$action( $part_pkg->add_freq($start, $months) );
310     }
311   }
312
313   # if this package has "free days" and delayed setup fee, tehn 
314   # set start date that many days in the future.
315   # (this should have been set in the UI, but enforce it here)
316   if (    ! $options{'change'}
317        && ! $options{'import'}
318        && ( my $free_days = $part_pkg->option('free_days',1) )
319        && $part_pkg->option('delay_setup',1)
320        #&& ! $self->start_date
321      )
322   {
323     $self->start_date( $part_pkg->default_start_date );
324   }
325
326   # set order date unless it was specified as part of an import
327   $self->order_date(time) unless $options{'import'} && $self->order_date;
328
329   local $SIG{HUP} = 'IGNORE';
330   local $SIG{INT} = 'IGNORE';
331   local $SIG{QUIT} = 'IGNORE';
332   local $SIG{TERM} = 'IGNORE';
333   local $SIG{TSTP} = 'IGNORE';
334   local $SIG{PIPE} = 'IGNORE';
335
336   my $oldAutoCommit = $FS::UID::AutoCommit;
337   local $FS::UID::AutoCommit = 0;
338   my $dbh = dbh;
339
340   $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
341   if ( $error ) {
342     $dbh->rollback if $oldAutoCommit;
343     return $error;
344   }
345
346   $self->refnum($self->cust_main->refnum) unless $self->refnum;
347   $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
348   $self->process_m2m( 'link_table'   => 'pkg_referral',
349                       'target_table' => 'part_referral',
350                       'params'       => $self->refnum,
351                     );
352
353   if ( $self->discountnum ) {
354     my $error = $self->insert_discount();
355     if ( $error ) {
356       $dbh->rollback if $oldAutoCommit;
357       return $error;
358     }
359   }
360
361   my $conf = new FS::Conf;
362
363   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
364
365     #this init stuff is still inefficient, but at least its limited to 
366     # the small number (any?) folks using ticket emailing on pkg order
367
368     #eval '
369     #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
370     #  use RT;
371     #';
372     #die $@ if $@;
373     #
374     #RT::LoadConfig();
375     #RT::Init();
376     use FS::TicketSystem;
377     FS::TicketSystem->init();
378
379     my $q = new RT::Queue($RT::SystemUser);
380     $q->Load($options{ticket_queue}) if $options{ticket_queue};
381     my $t = new RT::Ticket($RT::SystemUser);
382     my $mime = new MIME::Entity;
383     $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
384     $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
385                 Subject => $options{ticket_subject},
386                 MIMEObj => $mime,
387               );
388     $t->AddLink( Type   => 'MemberOf',
389                  Target => 'freeside://freeside/cust_main/'. $self->custnum,
390                );
391   }
392
393   if (! $options{'import'} && $conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
394     my $queue = new FS::queue {
395       'job'     => 'FS::cust_main::queueable_print',
396     };
397     $error = $queue->insert(
398       'custnum'  => $self->custnum,
399       'template' => 'welcome_letter',
400     );
401
402     if ($error) {
403       warn "can't send welcome letter: $error";
404     }
405
406   }
407
408   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
409   '';
410
411 }
412
413 =item delete
414
415 This method now works but you probably shouldn't use it.
416
417 You don't want to delete packages, because there would then be no record
418 the customer ever purchased the package.  Instead, see the cancel method and
419 hide cancelled packages.
420
421 =cut
422
423 sub delete {
424   my $self = shift;
425
426   local $SIG{HUP} = 'IGNORE';
427   local $SIG{INT} = 'IGNORE';
428   local $SIG{QUIT} = 'IGNORE';
429   local $SIG{TERM} = 'IGNORE';
430   local $SIG{TSTP} = 'IGNORE';
431   local $SIG{PIPE} = 'IGNORE';
432
433   my $oldAutoCommit = $FS::UID::AutoCommit;
434   local $FS::UID::AutoCommit = 0;
435   my $dbh = dbh;
436
437   foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
438     my $error = $cust_pkg_discount->delete;
439     if ( $error ) {
440       $dbh->rollback if $oldAutoCommit;
441       return $error;
442     }
443   }
444   #cust_bill_pkg_discount?
445
446   foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
447     my $error = $cust_pkg_detail->delete;
448     if ( $error ) {
449       $dbh->rollback if $oldAutoCommit;
450       return $error;
451     }
452   }
453
454   foreach my $cust_pkg_reason (
455     qsearchs( {
456                 'table' => 'cust_pkg_reason',
457                 'hashref' => { 'pkgnum' => $self->pkgnum },
458               }
459             )
460   ) {
461     my $error = $cust_pkg_reason->delete;
462     if ( $error ) {
463       $dbh->rollback if $oldAutoCommit;
464       return $error;
465     }
466   }
467
468   #pkg_referral?
469
470   my $error = $self->SUPER::delete(@_);
471   if ( $error ) {
472     $dbh->rollback if $oldAutoCommit;
473     return $error;
474   }
475
476   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
477
478   '';
479
480 }
481
482 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
483
484 Replaces the OLD_RECORD with this one in the database.  If there is an error,
485 returns the error, otherwise returns false.
486
487 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
488
489 Changing pkgpart may have disasterous effects.  See the order subroutine.
490
491 setup and bill are normally updated by calling the bill method of a customer
492 object (see L<FS::cust_main>).
493
494 suspend is normally updated by the suspend and unsuspend methods.
495
496 cancel is normally updated by the cancel method (and also the order subroutine
497 in some cases).
498
499 Available options are:
500
501 =over 4
502
503 =item reason
504
505 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.
506
507 =item reason_otaker
508
509 the access_user (see L<FS::access_user>) providing the reason
510
511 =item options
512
513 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
514
515 =back
516
517 =cut
518
519 sub replace {
520   my $new = shift;
521
522   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
523               ? shift
524               : $new->replace_old;
525
526   my $options = 
527     ( ref($_[0]) eq 'HASH' )
528       ? shift
529       : { @_ };
530
531   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
532   #return "Can't change otaker!" if $old->otaker ne $new->otaker;
533
534   #allow this *sigh*
535   #return "Can't change setup once it exists!"
536   #  if $old->getfield('setup') &&
537   #     $old->getfield('setup') != $new->getfield('setup');
538
539   #some logic for bill, susp, cancel?
540
541   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
542
543   local $SIG{HUP} = 'IGNORE';
544   local $SIG{INT} = 'IGNORE';
545   local $SIG{QUIT} = 'IGNORE';
546   local $SIG{TERM} = 'IGNORE';
547   local $SIG{TSTP} = 'IGNORE';
548   local $SIG{PIPE} = 'IGNORE';
549
550   my $oldAutoCommit = $FS::UID::AutoCommit;
551   local $FS::UID::AutoCommit = 0;
552   my $dbh = dbh;
553
554   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
555     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
556       my $error = $new->insert_reason(
557         'reason'        => $options->{'reason'},
558         'date'          => $new->$method,
559         'action'        => $method,
560         'reason_otaker' => $options->{'reason_otaker'},
561       );
562       if ( $error ) {
563         dbh->rollback if $oldAutoCommit;
564         return "Error inserting cust_pkg_reason: $error";
565       }
566     }
567   }
568
569   #save off and freeze RADIUS attributes for any associated svc_acct records
570   my @svc_acct = ();
571   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
572
573                 #also check for specific exports?
574                 # to avoid spurious modify export events
575     @svc_acct = map  { $_->svc_x }
576                 grep { $_->part_svc->svcdb eq 'svc_acct' }
577                      $old->cust_svc;
578
579     $_->snapshot foreach @svc_acct;
580
581   }
582
583   my $error =  $new->export_pkg_change($old)
584             || $new->SUPER::replace( $old,
585                                      $options->{options}
586                                        ? $options->{options}
587                                        : ()
588                                    );
589   if ( $error ) {
590     $dbh->rollback if $oldAutoCommit;
591     return $error;
592   }
593
594   #for prepaid packages,
595   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
596   foreach my $old_svc_acct ( @svc_acct ) {
597     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
598     my $s_error =
599       $new_svc_acct->replace( $old_svc_acct,
600                               'depend_jobnum' => $options->{depend_jobnum},
601                             );
602     if ( $s_error ) {
603       $dbh->rollback if $oldAutoCommit;
604       return $s_error;
605     }
606   }
607
608   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
609   '';
610
611 }
612
613 =item check
614
615 Checks all fields to make sure this is a valid billing item.  If there is an
616 error, returns the error, otherwise returns false.  Called by the insert and
617 replace methods.
618
619 =cut
620
621 sub check {
622   my $self = shift;
623
624   if ( !$self->locationnum or $self->locationnum == -1 ) {
625     $self->set('locationnum', $self->cust_main->ship_locationnum);
626   }
627
628   my $error = 
629     $self->ut_numbern('pkgnum')
630     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
631     || $self->ut_numbern('pkgpart')
632     || $self->ut_foreign_keyn('contactnum',  'contact',       'contactnum' )
633     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
634     || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum')
635     || $self->ut_numbern('quantity')
636     || $self->ut_numbern('start_date')
637     || $self->ut_numbern('setup')
638     || $self->ut_numbern('bill')
639     || $self->ut_numbern('susp')
640     || $self->ut_numbern('cancel')
641     || $self->ut_numbern('adjourn')
642     || $self->ut_numbern('resume')
643     || $self->ut_numbern('expire')
644     || $self->ut_numbern('dundate')
645     || $self->ut_enum('no_auto', [ '', 'Y' ])
646     || $self->ut_enum('waive_setup', [ '', 'Y' ])
647     || $self->ut_numbern('agent_pkgid')
648     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
649     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
650     || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
651     || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
652     || $self->ut_foreign_keyn('change_to_pkgnum', 'cust_pkg', 'pkgnum')
653   ;
654   return $error if $error;
655
656   return "A package with both start date (future start) and setup date (already started) will never bill"
657     if $self->start_date && $self->setup;
658
659   return "A future unsuspend date can only be set for a package with a suspend date"
660     if $self->resume and !$self->susp and !$self->adjourn;
661
662   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
663
664   if ( $self->dbdef_table->column('manual_flag') ) {
665     $self->manual_flag('') if $self->manual_flag eq ' ';
666     $self->manual_flag =~ /^([01]?)$/
667       or return "Illegal manual_flag ". $self->manual_flag;
668     $self->manual_flag($1);
669   }
670
671   $self->SUPER::check;
672 }
673
674 =item check_pkgpart
675
676 Check the pkgpart to make sure it's allowed with the reg_code and/or
677 promo_code of the package (if present) and with the customer's agent.
678 Called from C<insert>, unless we are doing a package change that doesn't
679 affect pkgpart.
680
681 =cut
682
683 sub check_pkgpart {
684   my $self = shift;
685
686   # my $error = $self->ut_numbern('pkgpart'); # already done
687
688   my $error;
689   if ( $self->reg_code ) {
690
691     unless ( grep { $self->pkgpart == $_->pkgpart }
692              map  { $_->reg_code_pkg }
693              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
694                                      'agentnum' => $self->cust_main->agentnum })
695            ) {
696       return "Unknown registration code";
697     }
698
699   } elsif ( $self->promo_code ) {
700
701     my $promo_part_pkg =
702       qsearchs('part_pkg', {
703         'pkgpart'    => $self->pkgpart,
704         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
705       } );
706     return 'Unknown promotional code' unless $promo_part_pkg;
707
708   } else { 
709
710     unless ( $disable_agentcheck ) {
711       my $agent =
712         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
713       return "agent ". $agent->agentnum. ':'. $agent->agent.
714              " can't purchase pkgpart ". $self->pkgpart
715         unless $agent->pkgpart_hashref->{ $self->pkgpart }
716             || $agent->agentnum == $self->part_pkg->agentnum;
717     }
718
719     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
720     return $error if $error;
721
722   }
723
724   '';
725
726 }
727
728 =item cancel [ OPTION => VALUE ... ]
729
730 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
731 in this package, then cancels the package itself (sets the cancel field to
732 now).
733
734 Available options are:
735
736 =over 4
737
738 =item quiet - can be set true to supress email cancellation notices.
739
740 =item time -  can be set to cancel the package based on a specific future or 
741 historical date.  Using time ensures that the remaining amount is calculated 
742 correctly.  Note however that this is an immediate cancel and just changes 
743 the date.  You are PROBABLY looking to expire the account instead of using 
744 this.
745
746 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
747 either a reasonnum of an existing reason, or passing a hashref will create 
748 a new reason.  The hashref should have the following keys: typenum - Reason 
749 type (see L<FS::reason_type>, reason - Text of the new reason.
750
751 =item date - can be set to a unix style timestamp to specify when to 
752 cancel (expire)
753
754 =item nobill - can be set true to skip billing if it might otherwise be done.
755
756 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
757 not credit it.  This must be set (by change()) when changing the package 
758 to a different pkgpart or location, and probably shouldn't be in any other 
759 case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
760 be used.
761
762 =back
763
764 If there is an error, returns the error, otherwise returns false.
765
766 =cut
767
768 sub cancel {
769   my( $self, %options ) = @_;
770   my $error;
771
772   # pass all suspend/cancel actions to the main package
773   if ( $self->main_pkgnum and !$options{'from_main'} ) {
774     return $self->main_pkg->cancel(%options);
775   }
776
777   my $conf = new FS::Conf;
778
779   warn "cust_pkg::cancel called with options".
780        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
781     if $DEBUG;
782
783   local $SIG{HUP} = 'IGNORE';
784   local $SIG{INT} = 'IGNORE';
785   local $SIG{QUIT} = 'IGNORE'; 
786   local $SIG{TERM} = 'IGNORE';
787   local $SIG{TSTP} = 'IGNORE';
788   local $SIG{PIPE} = 'IGNORE';
789
790   my $oldAutoCommit = $FS::UID::AutoCommit;
791   local $FS::UID::AutoCommit = 0;
792   my $dbh = dbh;
793   
794   my $old = $self->select_for_update;
795
796   if ( $old->get('cancel') || $self->get('cancel') ) {
797     dbh->rollback if $oldAutoCommit;
798     return "";  # no error
799   }
800
801   # XXX possibly set cancel_time to the expire date?
802   my $cancel_time = $options{'time'} || time;
803   my $date = $options{'date'} if $options{'date'}; # expire/cancel later
804   $date = '' if ($date && $date <= $cancel_time);      # complain instead?
805
806   #race condition: usage could be ongoing until unprovisioned
807   #resolved by performing a change package instead (which unprovisions) and
808   #later cancelling
809   if ( !$options{nobill} && !$date ) {
810     # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
811       my $copy = $self->new({$self->hash});
812       my $error =
813         $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
814                                 'cancel'   => 1,
815                                 'time'     => $cancel_time );
816       warn "Error billing during cancel, custnum ".
817         #$self->cust_main->custnum. ": $error"
818         ": $error"
819         if $error;
820   }
821
822   if ( $options{'reason'} ) {
823     $error = $self->insert_reason( 'reason' => $options{'reason'},
824                                    'action' => $date ? 'expire' : 'cancel',
825                                    'date'   => $date ? $date : $cancel_time,
826                                    'reason_otaker' => $options{'reason_otaker'},
827                                  );
828     if ( $error ) {
829       dbh->rollback if $oldAutoCommit;
830       return "Error inserting cust_pkg_reason: $error";
831     }
832   }
833
834   my %svc_cancel_opt = ();
835   $svc_cancel_opt{'date'} = $date if $date;
836   foreach my $cust_svc (
837     #schwartz
838     map  { $_->[0] }
839     sort { $a->[1] <=> $b->[1] }
840     map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
841     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
842   ) {
843     my $part_svc = $cust_svc->part_svc;
844     next if ( defined($part_svc) and $part_svc->preserve );
845     my $error = $cust_svc->cancel( %svc_cancel_opt );
846
847     if ( $error ) {
848       $dbh->rollback if $oldAutoCommit;
849       return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
850              " cust_svc: $error";
851     }
852   }
853
854   unless ($date) {
855     # credit remaining time if appropriate
856     my $do_credit;
857     if ( exists($options{'unused_credit'}) ) {
858       $do_credit = $options{'unused_credit'};
859     }
860     else {
861       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
862     }
863     if ( $do_credit ) {
864       my $error = $self->credit_remaining('cancel', $cancel_time);
865       if ($error) {
866         $dbh->rollback if $oldAutoCommit;
867         return $error;
868       }
869     }
870
871   } #unless $date
872
873   my %hash = $self->hash;
874   if ( $date ) {
875     $hash{'expire'} = $date;
876   } else {
877     $hash{'cancel'} = $cancel_time;
878   }
879   $hash{'change_custnum'} = $options{'change_custnum'};
880
881   my $new = new FS::cust_pkg ( \%hash );
882   $error = $new->replace( $self, options => { $self->options } );
883   if ( $self->change_to_pkgnum ) {
884     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
885     $error ||= $change_to->cancel || $change_to->delete;
886   }
887   if ( $error ) {
888     $dbh->rollback if $oldAutoCommit;
889     return $error;
890   }
891
892   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
893     $error = $supp_pkg->cancel(%options, 'from_main' => 1);
894     if ( $error ) {
895       $dbh->rollback if $oldAutoCommit;
896       return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
897     }
898   }
899
900   foreach my $usage ( $self->cust_pkg_usage ) {
901     $error = $usage->delete;
902     if ( $error ) {
903       $dbh->rollback if $oldAutoCommit;
904       return "deleting usage pools: $error";
905     }
906   }
907
908   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
909   return '' if $date; #no errors
910
911   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
912   if ( !$options{'quiet'} && 
913         $conf->exists('emailcancel', $self->cust_main->agentnum) && 
914         @invoicing_list ) {
915     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
916     my $error = '';
917     if ( $msgnum ) {
918       my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
919       $error = $msg_template->send( 'cust_main' => $self->cust_main,
920                                     'object'    => $self );
921     }
922     else {
923       $error = send_email(
924         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
925         'to'      => \@invoicing_list,
926         'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
927         'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
928       );
929     }
930     #should this do something on errors?
931   }
932
933   ''; #no errors
934
935 }
936
937 =item cancel_if_expired [ NOW_TIMESTAMP ]
938
939 Cancels this package if its expire date has been reached.
940
941 =cut
942
943 sub cancel_if_expired {
944   my $self = shift;
945   my $time = shift || time;
946   return '' unless $self->expire && $self->expire <= $time;
947   my $error = $self->cancel;
948   if ( $error ) {
949     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
950            $self->custnum. ": $error";
951   }
952   '';
953 }
954
955 =item uncancel
956
957 "Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
958 locationnum, (other fields?).  Attempts to re-provision cancelled services
959 using history information (errors at this stage are not fatal).
960
961 cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
962
963 svc_fatal: service provisioning errors are fatal
964
965 svc_errors: pass an array reference, will be filled in with any provisioning errors
966
967 main_pkgnum: link the package as a supplemental package of this one.  For 
968 internal use only.
969
970 =cut
971
972 sub uncancel {
973   my( $self, %options ) = @_;
974
975   #in case you try do do $uncancel-date = $cust_pkg->uncacel 
976   return '' unless $self->get('cancel');
977
978   if ( $self->main_pkgnum and !$options{'main_pkgnum'} ) {
979     return $self->main_pkg->uncancel(%options);
980   }
981
982   ##
983   # Transaction-alize
984   ##
985
986   local $SIG{HUP} = 'IGNORE';
987   local $SIG{INT} = 'IGNORE'; 
988   local $SIG{QUIT} = 'IGNORE';
989   local $SIG{TERM} = 'IGNORE';
990   local $SIG{TSTP} = 'IGNORE'; 
991   local $SIG{PIPE} = 'IGNORE'; 
992
993   my $oldAutoCommit = $FS::UID::AutoCommit;
994   local $FS::UID::AutoCommit = 0;
995   my $dbh = dbh;
996
997   ##
998   # insert the new package
999   ##
1000
1001   my $cust_pkg = new FS::cust_pkg {
1002     last_bill       => ( $options{'last_bill'} || $self->get('last_bill') ),
1003     bill            => ( $options{'bill'}      || $self->get('bill')      ),
1004     uncancel        => time,
1005     uncancel_pkgnum => $self->pkgnum,
1006     main_pkgnum     => ($options{'main_pkgnum'} || ''),
1007     map { $_ => $self->get($_) } qw(
1008       custnum pkgpart locationnum
1009       setup
1010       susp adjourn resume expire start_date contract_end dundate
1011       change_date change_pkgpart change_locationnum
1012       manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
1013     ),
1014   };
1015
1016   my $error = $cust_pkg->insert(
1017     'change' => 1, #supresses any referral credit to a referring customer
1018     'allow_pkgpart' => 1, # allow this even if the package def is disabled
1019   );
1020   if ($error) {
1021     $dbh->rollback if $oldAutoCommit;
1022     return $error;
1023   }
1024
1025   ##
1026   # insert services
1027   ##
1028
1029   #find historical services within this timeframe before the package cancel
1030   # (incompatible with "time" option to cust_pkg->cancel?)
1031   my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
1032                      #            too little? (unprovisioing export delay?)
1033   my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
1034   my @h_cust_svc = $self->h_cust_svc( $end, $start );
1035
1036   my @svc_errors;
1037   foreach my $h_cust_svc (@h_cust_svc) {
1038     my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
1039     #next unless $h_svc_x; #should this happen?
1040     (my $table = $h_svc_x->table) =~ s/^h_//;
1041     require "FS/$table.pm";
1042     my $class = "FS::$table";
1043     my $svc_x = $class->new( {
1044       'pkgnum'  => $cust_pkg->pkgnum,
1045       'svcpart' => $h_cust_svc->svcpart,
1046       map { $_ => $h_svc_x->get($_) } fields($table)
1047     } );
1048
1049     # radius_usergroup
1050     if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
1051       $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
1052     }
1053
1054     my $svc_error = $svc_x->insert;
1055     if ( $svc_error ) {
1056       if ( $options{svc_fatal} ) {
1057         $dbh->rollback if $oldAutoCommit;
1058         return $svc_error;
1059       } else {
1060         # if we've failed to insert the svc_x object, svc_Common->insert 
1061         # will have removed the cust_svc already.  if not, then both records
1062         # were inserted but we failed for some other reason (export, most 
1063         # likely).  in that case, report the error and delete the records.
1064         push @svc_errors, $svc_error;
1065         my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
1066         if ( $cust_svc ) {
1067           # except if export_insert failed, export_delete probably won't be
1068           # much better
1069           local $FS::svc_Common::noexport_hack = 1;
1070           my $cleanup_error = $svc_x->delete; # also deletes cust_svc
1071           if ( $cleanup_error ) { # and if THAT fails, then run away
1072             $dbh->rollback if $oldAutoCommit;
1073             return $cleanup_error;
1074           }
1075         }
1076       } # svc_fatal
1077     } # svc_error
1078   } #foreach $h_cust_svc
1079
1080   #these are pretty rare, but should handle them
1081   # - dsl_device (mac addresses)
1082   # - phone_device (mac addresses)
1083   # - dsl_note (ikano notes)
1084   # - domain_record (i.e. restore DNS information w/domains)
1085   # - inventory_item(?) (inventory w/un-cancelling service?)
1086   # - nas (svc_broaband nas stuff)
1087   #this stuff is unused in the wild afaik
1088   # - mailinglistmember
1089   # - router.svcnum?
1090   # - svc_domain.parent_svcnum?
1091   # - acct_snarf (ancient mail fetching config)
1092   # - cgp_rule (communigate)
1093   # - cust_svc_option (used by our Tron stuff)
1094   # - acct_rt_transaction (used by our time worked stuff)
1095
1096   ##
1097   # also move over any services that didn't unprovision at cancellation
1098   ## 
1099
1100   foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
1101     $cust_svc->pkgnum( $cust_pkg->pkgnum );
1102     my $error = $cust_svc->replace;
1103     if ( $error ) {
1104       $dbh->rollback if $oldAutoCommit;
1105       return $error;
1106     }
1107   }
1108
1109   ##
1110   # Uncancel any supplemental packages, and make them supplemental to the 
1111   # new one.
1112   ##
1113
1114   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1115     my $new_pkg;
1116     $error = $supp_pkg->uncancel(%options, 'main_pkgnum' => $cust_pkg->pkgnum);
1117     if ( $error ) {
1118       $dbh->rollback if $oldAutoCommit;
1119       return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
1120     }
1121   }
1122
1123   ##
1124   # Finish
1125   ##
1126
1127   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1128
1129   ${ $options{cust_pkg} }   = $cust_pkg   if ref($options{cust_pkg});
1130   @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
1131
1132   '';
1133 }
1134
1135 =item unexpire
1136
1137 Cancels any pending expiration (sets the expire field to null).
1138
1139 If there is an error, returns the error, otherwise returns false.
1140
1141 =cut
1142
1143 sub unexpire {
1144   my( $self, %options ) = @_;
1145   my $error;
1146
1147   local $SIG{HUP} = 'IGNORE';
1148   local $SIG{INT} = 'IGNORE';
1149   local $SIG{QUIT} = 'IGNORE';
1150   local $SIG{TERM} = 'IGNORE';
1151   local $SIG{TSTP} = 'IGNORE';
1152   local $SIG{PIPE} = 'IGNORE';
1153
1154   my $oldAutoCommit = $FS::UID::AutoCommit;
1155   local $FS::UID::AutoCommit = 0;
1156   my $dbh = dbh;
1157
1158   my $old = $self->select_for_update;
1159
1160   my $pkgnum = $old->pkgnum;
1161   if ( $old->get('cancel') || $self->get('cancel') ) {
1162     dbh->rollback if $oldAutoCommit;
1163     return "Can't unexpire cancelled package $pkgnum";
1164     # or at least it's pointless
1165   }
1166
1167   unless ( $old->get('expire') && $self->get('expire') ) {
1168     dbh->rollback if $oldAutoCommit;
1169     return "";  # no error
1170   }
1171
1172   my %hash = $self->hash;
1173   $hash{'expire'} = '';
1174   my $new = new FS::cust_pkg ( \%hash );
1175   $error = $new->replace( $self, options => { $self->options } );
1176   if ( $error ) {
1177     $dbh->rollback if $oldAutoCommit;
1178     return $error;
1179   }
1180
1181   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1182
1183   ''; #no errors
1184
1185 }
1186
1187 =item suspend [ OPTION => VALUE ... ]
1188
1189 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1190 package, then suspends the package itself (sets the susp field to now).
1191
1192 Available options are:
1193
1194 =over 4
1195
1196 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
1197 either a reasonnum of an existing reason, or passing a hashref will create 
1198 a new reason.  The hashref should have the following keys: 
1199 - typenum - Reason type (see L<FS::reason_type>
1200 - reason - Text of the new reason.
1201
1202 =item date - can be set to a unix style timestamp to specify when to 
1203 suspend (adjourn)
1204
1205 =item time - can be set to override the current time, for calculation 
1206 of final invoices or unused-time credits
1207
1208 =item resume_date - can be set to a time when the package should be 
1209 unsuspended.  This may be more convenient than calling C<unsuspend()>
1210 separately.
1211
1212 =item from_main - allows a supplemental package to be suspended, rather
1213 than redirecting the method call to its main package.  For internal use.
1214
1215 =back
1216
1217 If there is an error, returns the error, otherwise returns false.
1218
1219 =cut
1220
1221 sub suspend {
1222   my( $self, %options ) = @_;
1223   my $error;
1224
1225   # pass all suspend/cancel actions to the main package
1226   if ( $self->main_pkgnum and !$options{'from_main'} ) {
1227     return $self->main_pkg->suspend(%options);
1228   }
1229
1230   local $SIG{HUP} = 'IGNORE';
1231   local $SIG{INT} = 'IGNORE';
1232   local $SIG{QUIT} = 'IGNORE'; 
1233   local $SIG{TERM} = 'IGNORE';
1234   local $SIG{TSTP} = 'IGNORE';
1235   local $SIG{PIPE} = 'IGNORE';
1236
1237   my $oldAutoCommit = $FS::UID::AutoCommit;
1238   local $FS::UID::AutoCommit = 0;
1239   my $dbh = dbh;
1240
1241   my $old = $self->select_for_update;
1242
1243   my $pkgnum = $old->pkgnum;
1244   if ( $old->get('cancel') || $self->get('cancel') ) {
1245     dbh->rollback if $oldAutoCommit;
1246     return "Can't suspend cancelled package $pkgnum";
1247   }
1248
1249   if ( $old->get('susp') || $self->get('susp') ) {
1250     dbh->rollback if $oldAutoCommit;
1251     return "";  # no error                     # complain on adjourn?
1252   }
1253
1254   my $suspend_time = $options{'time'} || time;
1255   my $date = $options{date} if $options{date}; # adjourn/suspend later
1256   $date = '' if ($date && $date <= $suspend_time); # complain instead?
1257
1258   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
1259     dbh->rollback if $oldAutoCommit;
1260     return "Package $pkgnum expires before it would be suspended.";
1261   }
1262
1263   # some false laziness with sub cancel
1264   if ( !$options{nobill} && !$date &&
1265        $self->part_pkg->option('bill_suspend_as_cancel',1) ) {
1266     # kind of a kludge--'bill_suspend_as_cancel' to avoid having to 
1267     # make the entire cust_main->bill path recognize 'suspend' and 
1268     # 'cancel' separately.
1269     warn "Billing $pkgnum on suspension (at $suspend_time)\n" if $DEBUG;
1270     my $copy = $self->new({$self->hash});
1271     my $error =
1272       $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
1273                               'cancel'   => 1,
1274                               'time'     => $suspend_time );
1275     warn "Error billing during suspend, custnum ".
1276       #$self->cust_main->custnum. ": $error"
1277       ": $error"
1278       if $error;
1279   }
1280
1281   if ( $options{'reason'} ) {
1282     $error = $self->insert_reason( 'reason' => $options{'reason'},
1283                                    'action' => $date ? 'adjourn' : 'suspend',
1284                                    'date'   => $date ? $date : $suspend_time,
1285                                    'reason_otaker' => $options{'reason_otaker'},
1286                                  );
1287     if ( $error ) {
1288       dbh->rollback if $oldAutoCommit;
1289       return "Error inserting cust_pkg_reason: $error";
1290     }
1291   }
1292
1293   my %hash = $self->hash;
1294   if ( $date ) {
1295     $hash{'adjourn'} = $date;
1296   } else {
1297     $hash{'susp'} = $suspend_time;
1298   }
1299
1300   my $resume_date = $options{'resume_date'} || 0;
1301   if ( $resume_date > ($date || $suspend_time) ) {
1302     $hash{'resume'} = $resume_date;
1303   }
1304
1305   $options{options} ||= {};
1306
1307   my $new = new FS::cust_pkg ( \%hash );
1308   $error = $new->replace( $self, options => { $self->options,
1309                                               %{ $options{options} },
1310                                             }
1311                         );
1312   if ( $error ) {
1313     $dbh->rollback if $oldAutoCommit;
1314     return $error;
1315   }
1316
1317   unless ( $date ) {
1318     # credit remaining time if appropriate
1319     if ( $self->part_pkg->option('unused_credit_suspend', 1) ) {
1320       my $error = $self->credit_remaining('suspend', $suspend_time);
1321       if ($error) {
1322         $dbh->rollback if $oldAutoCommit;
1323         return $error;
1324       }
1325     }
1326
1327     my @labels = ();
1328
1329     foreach my $cust_svc (
1330       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
1331     ) {
1332       my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1333
1334       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1335         $dbh->rollback if $oldAutoCommit;
1336         return "Illegal svcdb value in part_svc!";
1337       };
1338       my $svcdb = $1;
1339       require "FS/$svcdb.pm";
1340
1341       my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1342       if ($svc) {
1343         $error = $svc->suspend;
1344         if ( $error ) {
1345           $dbh->rollback if $oldAutoCommit;
1346           return $error;
1347         }
1348         my( $label, $value ) = $cust_svc->label;
1349         push @labels, "$label: $value";
1350       }
1351     }
1352
1353     my $conf = new FS::Conf;
1354     if ( $conf->config('suspend_email_admin') ) {
1355  
1356       my $error = send_email(
1357         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1358                                    #invoice_from ??? well as good as any
1359         'to'      => $conf->config('suspend_email_admin'),
1360         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
1361         'body'    => [
1362           "This is an automatic message from your Freeside installation\n",
1363           "informing you that the following customer package has been suspended:\n",
1364           "\n",
1365           'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1366           'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1367           ( map { "Service : $_\n" } @labels ),
1368         ],
1369       );
1370
1371       if ( $error ) {
1372         warn "WARNING: can't send suspension admin email (suspending anyway): ".
1373              "$error\n";
1374       }
1375
1376     }
1377
1378   }
1379
1380   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1381     $error = $supp_pkg->suspend(%options, 'from_main' => 1);
1382     if ( $error ) {
1383       $dbh->rollback if $oldAutoCommit;
1384       return "suspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1385     }
1386   }
1387
1388   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1389
1390   ''; #no errors
1391 }
1392
1393 =item credit_remaining MODE TIME
1394
1395 Generate a credit for this package for the time remaining in the current 
1396 billing period.  MODE is either "suspend" or "cancel" (determines the 
1397 credit type).  TIME is the time of suspension/cancellation.  Both arguments
1398 are mandatory.
1399
1400 =cut
1401
1402 sub credit_remaining {
1403   # Add a credit for remaining service
1404   my ($self, $mode, $time) = @_;
1405   die 'credit_remaining requires suspend or cancel' 
1406     unless $mode eq 'suspend' or $mode eq 'cancel';
1407   die 'no suspend/cancel time' unless $time > 0;
1408
1409   my $conf = FS::Conf->new;
1410   my $reason_type = $conf->config($mode.'_credit_type');
1411
1412   my $last_bill = $self->getfield('last_bill') || 0;
1413   my $next_bill = $self->getfield('bill') || 0;
1414   if ( $last_bill > 0         # the package has been billed
1415       and $next_bill > 0      # the package has a next bill date
1416       and $next_bill >= $time # which is in the future
1417   ) {
1418     my $remaining_value = $self->calc_remain('time' => $time);
1419     if ( $remaining_value > 0 ) {
1420       warn "Crediting for $remaining_value on package ".$self->pkgnum."\n"
1421         if $DEBUG;
1422       my $error = $self->cust_main->credit(
1423         $remaining_value,
1424         'Credit for unused time on '. $self->part_pkg->pkg,
1425         'reason_type' => $reason_type,
1426       );
1427       return "Error crediting customer \$$remaining_value for unused time".
1428         " on ". $self->part_pkg->pkg. ": $error"
1429         if $error;
1430     } #if $remaining_value
1431   } #if $last_bill, etc.
1432   '';
1433 }
1434
1435 =item unsuspend [ OPTION => VALUE ... ]
1436
1437 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1438 package, then unsuspends the package itself (clears the susp field and the
1439 adjourn field if it is in the past).  If the suspend reason includes an 
1440 unsuspension package, that package will be ordered.
1441
1442 Available options are:
1443
1444 =over 4
1445
1446 =item date
1447
1448 Can be set to a date to unsuspend the package in the future (the 'resume' 
1449 field).
1450
1451 =item adjust_next_bill
1452
1453 Can be set true to adjust the next bill date forward by
1454 the amount of time the account was inactive.  This was set true by default
1455 in the past (from 1.4.2 and 1.5.0pre6 through 1.7.0), but now needs to be
1456 explicitly requested with this option or in the price plan.
1457
1458 =back
1459
1460 If there is an error, returns the error, otherwise returns false.
1461
1462 =cut
1463
1464 sub unsuspend {
1465   my( $self, %opt ) = @_;
1466   my $error;
1467
1468   # pass all suspend/cancel actions to the main package
1469   if ( $self->main_pkgnum and !$opt{'from_main'} ) {
1470     return $self->main_pkg->unsuspend(%opt);
1471   }
1472
1473   local $SIG{HUP} = 'IGNORE';
1474   local $SIG{INT} = 'IGNORE';
1475   local $SIG{QUIT} = 'IGNORE'; 
1476   local $SIG{TERM} = 'IGNORE';
1477   local $SIG{TSTP} = 'IGNORE';
1478   local $SIG{PIPE} = 'IGNORE';
1479
1480   my $oldAutoCommit = $FS::UID::AutoCommit;
1481   local $FS::UID::AutoCommit = 0;
1482   my $dbh = dbh;
1483
1484   my $old = $self->select_for_update;
1485
1486   my $pkgnum = $old->pkgnum;
1487   if ( $old->get('cancel') || $self->get('cancel') ) {
1488     $dbh->rollback if $oldAutoCommit;
1489     return "Can't unsuspend cancelled package $pkgnum";
1490   }
1491
1492   unless ( $old->get('susp') && $self->get('susp') ) {
1493     $dbh->rollback if $oldAutoCommit;
1494     return "";  # no error                     # complain instead?
1495   }
1496
1497   my $date = $opt{'date'};
1498   if ( $date and $date > time ) { # return an error if $date <= time?
1499
1500     if ( $old->get('expire') && $old->get('expire') < $date ) {
1501       $dbh->rollback if $oldAutoCommit;
1502       return "Package $pkgnum expires before it would be unsuspended.";
1503     }
1504
1505     my $new = new FS::cust_pkg { $self->hash };
1506     $new->set('resume', $date);
1507     $error = $new->replace($self, options => $self->options);
1508
1509     if ( $error ) {
1510       $dbh->rollback if $oldAutoCommit;
1511       return $error;
1512     }
1513     else {
1514       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1515       return '';
1516     }
1517   
1518   } #if $date 
1519
1520   my @labels = ();
1521
1522   foreach my $cust_svc (
1523     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1524   ) {
1525     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1526
1527     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1528       $dbh->rollback if $oldAutoCommit;
1529       return "Illegal svcdb value in part_svc!";
1530     };
1531     my $svcdb = $1;
1532     require "FS/$svcdb.pm";
1533
1534     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1535     if ($svc) {
1536       $error = $svc->unsuspend;
1537       if ( $error ) {
1538         $dbh->rollback if $oldAutoCommit;
1539         return $error;
1540       }
1541       my( $label, $value ) = $cust_svc->label;
1542       push @labels, "$label: $value";
1543     }
1544
1545   }
1546
1547   my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
1548   my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
1549
1550   my %hash = $self->hash;
1551   my $inactive = time - $hash{'susp'};
1552
1553   my $conf = new FS::Conf;
1554
1555   if ( $inactive > 0 && 
1556        ( $hash{'bill'} || $hash{'setup'} ) &&
1557        ( $opt{'adjust_next_bill'} ||
1558          $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1559          $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1560      ) {
1561
1562     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1563   
1564   }
1565
1566   $hash{'susp'} = '';
1567   $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
1568   $hash{'resume'} = '' if !$hash{'adjourn'};
1569   my $new = new FS::cust_pkg ( \%hash );
1570   $error = $new->replace( $self, options => { $self->options } );
1571   if ( $error ) {
1572     $dbh->rollback if $oldAutoCommit;
1573     return $error;
1574   }
1575
1576   my $unsusp_pkg;
1577
1578   if ( $reason && $reason->unsuspend_pkgpart ) {
1579     my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
1580       or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
1581                   " not found.";
1582     my $start_date = $self->cust_main->next_bill_date 
1583       if $reason->unsuspend_hold;
1584
1585     if ( $part_pkg ) {
1586       $unsusp_pkg = FS::cust_pkg->new({
1587           'custnum'     => $self->custnum,
1588           'pkgpart'     => $reason->unsuspend_pkgpart,
1589           'start_date'  => $start_date,
1590           'locationnum' => $self->locationnum,
1591           # discount? probably not...
1592       });
1593       
1594       $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
1595     }
1596
1597     if ( $error ) {
1598       $dbh->rollback if $oldAutoCommit;
1599       return $error;
1600     }
1601   }
1602
1603   if ( $conf->config('unsuspend_email_admin') ) {
1604  
1605     my $error = send_email(
1606       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1607                                  #invoice_from ??? well as good as any
1608       'to'      => $conf->config('unsuspend_email_admin'),
1609       'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
1610         "This is an automatic message from your Freeside installation\n",
1611         "informing you that the following customer package has been unsuspended:\n",
1612         "\n",
1613         'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1614         'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1615         ( map { "Service : $_\n" } @labels ),
1616         ($unsusp_pkg ?
1617           "An unsuspension fee was charged: ".
1618             $unsusp_pkg->part_pkg->pkg_comment."\n"
1619           : ''
1620         ),
1621       ],
1622     );
1623
1624     if ( $error ) {
1625       warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
1626            "$error\n";
1627     }
1628
1629   }
1630
1631   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1632     $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
1633     if ( $error ) {
1634       $dbh->rollback if $oldAutoCommit;
1635       return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1636     }
1637   }
1638
1639   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1640
1641   ''; #no errors
1642 }
1643
1644 =item unadjourn
1645
1646 Cancels any pending suspension (sets the adjourn field to null).
1647
1648 If there is an error, returns the error, otherwise returns false.
1649
1650 =cut
1651
1652 sub unadjourn {
1653   my( $self, %options ) = @_;
1654   my $error;
1655
1656   local $SIG{HUP} = 'IGNORE';
1657   local $SIG{INT} = 'IGNORE';
1658   local $SIG{QUIT} = 'IGNORE'; 
1659   local $SIG{TERM} = 'IGNORE';
1660   local $SIG{TSTP} = 'IGNORE';
1661   local $SIG{PIPE} = 'IGNORE';
1662
1663   my $oldAutoCommit = $FS::UID::AutoCommit;
1664   local $FS::UID::AutoCommit = 0;
1665   my $dbh = dbh;
1666
1667   my $old = $self->select_for_update;
1668
1669   my $pkgnum = $old->pkgnum;
1670   if ( $old->get('cancel') || $self->get('cancel') ) {
1671     dbh->rollback if $oldAutoCommit;
1672     return "Can't unadjourn cancelled package $pkgnum";
1673     # or at least it's pointless
1674   }
1675
1676   if ( $old->get('susp') || $self->get('susp') ) {
1677     dbh->rollback if $oldAutoCommit;
1678     return "Can't unadjourn suspended package $pkgnum";
1679     # perhaps this is arbitrary
1680   }
1681
1682   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1683     dbh->rollback if $oldAutoCommit;
1684     return "";  # no error
1685   }
1686
1687   my %hash = $self->hash;
1688   $hash{'adjourn'} = '';
1689   $hash{'resume'}  = '';
1690   my $new = new FS::cust_pkg ( \%hash );
1691   $error = $new->replace( $self, options => { $self->options } );
1692   if ( $error ) {
1693     $dbh->rollback if $oldAutoCommit;
1694     return $error;
1695   }
1696
1697   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1698
1699   ''; #no errors
1700
1701 }
1702
1703
1704 =item change HASHREF | OPTION => VALUE ... 
1705
1706 Changes this package: cancels it and creates a new one, with a different
1707 pkgpart or locationnum or both.  All services are transferred to the new
1708 package (no change will be made if this is not possible).
1709
1710 Options may be passed as a list of key/value pairs or as a hash reference.
1711 Options are:
1712
1713 =over 4
1714
1715 =item locationnum
1716
1717 New locationnum, to change the location for this package.
1718
1719 =item cust_location
1720
1721 New FS::cust_location object, to create a new location and assign it
1722 to this package.
1723
1724 =item cust_main
1725
1726 New FS::cust_main object, to create a new customer and assign the new package
1727 to it.
1728
1729 =item pkgpart
1730
1731 New pkgpart (see L<FS::part_pkg>).
1732
1733 =item refnum
1734
1735 New refnum (see L<FS::part_referral>).
1736
1737 =item quantity
1738
1739 New quantity; if unspecified, the new package will have the same quantity
1740 as the old.
1741
1742 =item cust_pkg
1743
1744 "New" (existing) FS::cust_pkg object.  The package's services and other 
1745 attributes will be transferred to this package.
1746
1747 =item keep_dates
1748
1749 Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
1750 susp, adjourn, cancel, expire, and contract_end) to the new package.
1751
1752 =item unprotect_svcs
1753
1754 Normally, change() will rollback and return an error if some services 
1755 can't be transferred (also see the I<cust_pkg-change_svcpart> config option).
1756 If unprotect_svcs is true, this method will transfer as many services as 
1757 it can and then unconditionally cancel the old package.
1758
1759 =back
1760
1761 At least one of locationnum, cust_location, pkgpart, refnum, cust_main, or
1762 cust_pkg must be specified (otherwise, what's the point?)
1763
1764 Returns either the new FS::cust_pkg object or a scalar error.
1765
1766 For example:
1767
1768   my $err_or_new_cust_pkg = $old_cust_pkg->change
1769
1770 =cut
1771
1772 #some false laziness w/order
1773 sub change {
1774   my $self = shift;
1775   my $opt = ref($_[0]) ? shift : { @_ };
1776
1777   my $conf = new FS::Conf;
1778
1779   # Transactionize this whole mess
1780   local $SIG{HUP} = 'IGNORE';
1781   local $SIG{INT} = 'IGNORE'; 
1782   local $SIG{QUIT} = 'IGNORE';
1783   local $SIG{TERM} = 'IGNORE';
1784   local $SIG{TSTP} = 'IGNORE'; 
1785   local $SIG{PIPE} = 'IGNORE'; 
1786
1787   my $oldAutoCommit = $FS::UID::AutoCommit;
1788   local $FS::UID::AutoCommit = 0;
1789   my $dbh = dbh;
1790
1791   my $error;
1792
1793   my %hash = (); 
1794
1795   my $time = time;
1796
1797   $hash{'setup'} = $time if $self->setup;
1798
1799   $hash{'change_date'} = $time;
1800   $hash{"change_$_"}  = $self->$_()
1801     foreach qw( pkgnum pkgpart locationnum );
1802
1803   if ( $opt->{'cust_location'} ) {
1804     $error = $opt->{'cust_location'}->find_or_insert;
1805     if ( $error ) {
1806       $dbh->rollback if $oldAutoCommit;
1807       return "inserting cust_location (transaction rolled back): $error";
1808     }
1809     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1810   }
1811
1812   if ( $opt->{'cust_pkg'} ) {
1813     # treat changing to a package with a different pkgpart as a 
1814     # pkgpart change (because it is)
1815     $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart;
1816   }
1817
1818   # whether to override pkgpart checking on the new package
1819   my $same_pkgpart = 1;
1820   if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
1821     $same_pkgpart = 0;
1822   }
1823
1824   my $unused_credit = 0;
1825   my $keep_dates = $opt->{'keep_dates'};
1826   # Special case.  If the pkgpart is changing, and the customer is
1827   # going to be credited for remaining time, don't keep setup, bill, 
1828   # or last_bill dates, and DO pass the flag to cancel() to credit 
1829   # the customer.
1830   if ( $opt->{'pkgpart'} 
1831        and $opt->{'pkgpart'} != $self->pkgpart
1832        and $self->part_pkg->option('unused_credit_change', 1) ) {
1833     $unused_credit = 1;
1834     $keep_dates = 0;
1835     $hash{$_} = '' foreach qw(setup bill last_bill);
1836   }
1837
1838   if ( $keep_dates ) {
1839     foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
1840                           resume start_date contract_end ) ) {
1841       $hash{$date} = $self->getfield($date);
1842     }
1843   }
1844
1845   # allow $opt->{'locationnum'} = '' to specifically set it to null
1846   # (i.e. customer default location)
1847   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
1848
1849   # usually this doesn't matter.  the two cases where it does are:
1850   # 1. unused_credit_change + pkgpart change + setup fee on the new package
1851   # and
1852   # 2. (more importantly) changing a package before it's billed
1853   $hash{'waive_setup'} = $self->waive_setup;
1854
1855   my $custnum = $self->custnum;
1856   if ( $opt->{cust_main} ) {
1857     my $cust_main = $opt->{cust_main};
1858     unless ( $cust_main->custnum ) { 
1859       my $error = $cust_main->insert;
1860       if ( $error ) {
1861         $dbh->rollback if $oldAutoCommit;
1862         return "inserting cust_main (transaction rolled back): $error";
1863       }
1864     }
1865     $custnum = $cust_main->custnum;
1866   }
1867
1868   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
1869
1870   my $cust_pkg;
1871   if ( $opt->{'cust_pkg'} ) {
1872     # The target package already exists; update it to show that it was 
1873     # changed from this package.
1874     $cust_pkg = $opt->{'cust_pkg'};
1875
1876     foreach ( qw( pkgnum pkgpart locationnum ) ) {
1877       $cust_pkg->set("change_$_", $self->get($_));
1878     }
1879     $cust_pkg->set('change_date', $time);
1880     $error = $cust_pkg->replace;
1881
1882   } else {
1883     # Create the new package.
1884     $cust_pkg = new FS::cust_pkg {
1885       custnum     => $custnum,
1886       locationnum => $opt->{'locationnum'},
1887       ( map {  $_ => ( $opt->{$_} || $self->$_() )  }
1888           qw( pkgpart quantity refnum salesnum )
1889       ),
1890       %hash,
1891     };
1892     $error = $cust_pkg->insert( 'change' => 1,
1893                                 'allow_pkgpart' => $same_pkgpart );
1894   }
1895   if ($error) {
1896     $dbh->rollback if $oldAutoCommit;
1897     return $error;
1898   }
1899
1900   # Transfer services and cancel old package.
1901
1902   $error = $self->transfer($cust_pkg);
1903   if ($error and $error == 0) {
1904     # $old_pkg->transfer failed.
1905     $dbh->rollback if $oldAutoCommit;
1906     return $error;
1907   }
1908
1909   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1910     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1911     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1912     if ($error and $error == 0) {
1913       # $old_pkg->transfer failed.
1914       $dbh->rollback if $oldAutoCommit;
1915       return $error;
1916     }
1917   }
1918
1919   # We set unprotect_svcs when executing a "future package change".  It's 
1920   # not a user-interactive operation, so returning an error means the 
1921   # package change will just fail.  Rather than have that happen, we'll 
1922   # let leftover services be deleted.
1923   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
1924     # Transfers were successful, but we still had services left on the old
1925     # package.  We can't change the package under this circumstances, so abort.
1926     $dbh->rollback if $oldAutoCommit;
1927     return "Unable to transfer all services from package ". $self->pkgnum;
1928   }
1929
1930   #reset usage if changing pkgpart
1931   # AND usage rollover is off (otherwise adds twice, now and at package bill)
1932   if ($self->pkgpart != $cust_pkg->pkgpart) {
1933     my $part_pkg = $cust_pkg->part_pkg;
1934     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1935                                                  ? ()
1936                                                  : ( 'null' => 1 )
1937                                    )
1938       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
1939
1940     if ($error) {
1941       $dbh->rollback if $oldAutoCommit;
1942       return "Error setting usage values: $error";
1943     }
1944   } else {
1945     # if NOT changing pkgpart, transfer any usage pools over
1946     foreach my $usage ($self->cust_pkg_usage) {
1947       $usage->set('pkgnum', $cust_pkg->pkgnum);
1948       $error = $usage->replace;
1949       if ( $error ) {
1950         $dbh->rollback if $oldAutoCommit;
1951         return "Error transferring usage pools: $error";
1952       }
1953     }
1954   }
1955
1956   # transfer discounts, if we're not changing pkgpart
1957   if ( $same_pkgpart ) {
1958     foreach my $old_discount ($self->cust_pkg_discount_active) {
1959       # don't remove the old discount, we may still need to bill that package.
1960       my $new_discount = new FS::cust_pkg_discount {
1961         'pkgnum'      => $cust_pkg->pkgnum,
1962         'discountnum' => $old_discount->discountnum,
1963         'months_used' => $old_discount->months_used,
1964       };
1965       $error = $new_discount->insert;
1966       if ( $error ) {
1967         $dbh->rollback if $oldAutoCommit;
1968         return "Error transferring discounts: $error";
1969       }
1970     }
1971   }
1972
1973   # transfer (copy) invoice details
1974   foreach my $detail ($self->cust_pkg_detail) {
1975     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
1976     $new_detail->set('pkgdetailnum', '');
1977     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
1978     $error = $new_detail->insert;
1979     if ( $error ) {
1980       $dbh->rollback if $oldAutoCommit;
1981       return "Error transferring package notes: $error";
1982     }
1983   }
1984   
1985   my @new_supp_pkgs;
1986
1987   if ( !$opt->{'cust_pkg'} ) {
1988     # Order any supplemental packages.
1989     my $part_pkg = $cust_pkg->part_pkg;
1990     my @old_supp_pkgs = $self->supplemental_pkgs;
1991     foreach my $link ($part_pkg->supp_part_pkg_link) {
1992       my $old;
1993       foreach (@old_supp_pkgs) {
1994         if ($_->pkgpart == $link->dst_pkgpart) {
1995           $old = $_;
1996           $_->pkgpart(0); # so that it can't match more than once
1997         }
1998         last if $old;
1999       }
2000       # false laziness with FS::cust_main::Packages::order_pkg
2001       my $new = FS::cust_pkg->new({
2002           pkgpart       => $link->dst_pkgpart,
2003           pkglinknum    => $link->pkglinknum,
2004           custnum       => $custnum,
2005           main_pkgnum   => $cust_pkg->pkgnum,
2006           locationnum   => $cust_pkg->locationnum,
2007           start_date    => $cust_pkg->start_date,
2008           order_date    => $cust_pkg->order_date,
2009           expire        => $cust_pkg->expire,
2010           adjourn       => $cust_pkg->adjourn,
2011           contract_end  => $cust_pkg->contract_end,
2012           refnum        => $cust_pkg->refnum,
2013           discountnum   => $cust_pkg->discountnum,
2014           waive_setup   => $cust_pkg->waive_setup,
2015       });
2016       if ( $old and $opt->{'keep_dates'} ) {
2017         foreach (qw(setup bill last_bill)) {
2018           $new->set($_, $old->get($_));
2019         }
2020       }
2021       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2022       # transfer services
2023       if ( $old ) {
2024         $error ||= $old->transfer($new);
2025       }
2026       if ( $error and $error > 0 ) {
2027         # no reason why this should ever fail, but still...
2028         $error = "Unable to transfer all services from supplemental package ".
2029           $old->pkgnum;
2030       }
2031       if ( $error ) {
2032         $dbh->rollback if $oldAutoCommit;
2033         return $error;
2034       }
2035       push @new_supp_pkgs, $new;
2036     }
2037   } # if !$opt->{'cust_pkg'}
2038     # because if there is one, then supplemental packages would already
2039     # have been created for it.
2040
2041   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2042   #remaining time.
2043   #Don't allow billing the package (preceding period packages and/or 
2044   #outstanding usage) if we are keeping dates (i.e. location changing), 
2045   #because the new package will be billed for the same date range.
2046   #Supplemental packages are also canceled here.
2047
2048   # during scheduled changes, avoid canceling the package we just
2049   # changed to (duh)
2050   $self->set('change_to_pkgnum' => '');
2051
2052   $error = $self->cancel(
2053     quiet          => 1, 
2054     unused_credit  => $unused_credit,
2055     nobill         => $keep_dates,
2056     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2057   );
2058   if ($error) {
2059     $dbh->rollback if $oldAutoCommit;
2060     return $error;
2061   }
2062
2063   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2064     #$self->cust_main
2065     my $error = $cust_pkg->cust_main->bill( 
2066       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2067     );
2068     if ( $error ) {
2069       $dbh->rollback if $oldAutoCommit;
2070       return $error;
2071     }
2072   }
2073
2074   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2075
2076   $cust_pkg;
2077
2078 }
2079
2080 =item change_later OPTION => VALUE...
2081
2082 Schedule a package change for a later date.  This actually orders the new
2083 package immediately, but sets its start date for a future date, and sets
2084 the current package to expire on the same date.
2085
2086 If the package is already scheduled for a change, this can be called with 
2087 'start_date' to change the scheduled date, or with pkgpart and/or 
2088 locationnum to modify the package change.  To cancel the scheduled change 
2089 entirely, see C<abort_change>.
2090
2091 Options include:
2092
2093 =over 4
2094
2095 =item start_date
2096
2097 The date for the package change.  Required, and must be in the future.
2098
2099 =item pkgpart
2100
2101 =item locationnum
2102
2103 =item quantity
2104
2105 The pkgpart. locationnum, and quantity of the new package, with the same 
2106 meaning as in C<change>.
2107
2108 =back
2109
2110 =cut
2111
2112 sub change_later {
2113   my $self = shift;
2114   my $opt = ref($_[0]) ? shift : { @_ };
2115
2116   my $oldAutoCommit = $FS::UID::AutoCommit;
2117   local $FS::UID::AutoCommit = 0;
2118   my $dbh = dbh;
2119
2120   my $cust_main = $self->cust_main;
2121
2122   my $date = delete $opt->{'start_date'} or return 'start_date required';
2123  
2124   if ( $date <= time ) {
2125     $dbh->rollback if $oldAutoCommit;
2126     return "start_date $date is in the past";
2127   }
2128
2129   my $error;
2130
2131   if ( $self->change_to_pkgnum ) {
2132     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2133     my $new_pkgpart = $opt->{'pkgpart'}
2134         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2135     my $new_locationnum = $opt->{'locationnum'}
2136         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2137     my $new_quantity = $opt->{'quantity'}
2138         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2139     if ( $new_pkgpart or $new_locationnum or $new_quantity ) {
2140       # it hasn't been billed yet, so in principle we could just edit
2141       # it in place (w/o a package change), but that's bad form.
2142       # So change the package according to the new options...
2143       my $err_or_pkg = $change_to->change(%$opt);
2144       if ( ref $err_or_pkg ) {
2145         # Then set that package up for a future start.
2146         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2147         $self->set('expire', $date); # in case it's different
2148         $err_or_pkg->set('start_date', $date);
2149         $err_or_pkg->set('change_date', '');
2150         $err_or_pkg->set('change_pkgnum', '');
2151
2152         $error = $self->replace       ||
2153                  $err_or_pkg->replace ||
2154                  $change_to->cancel   ||
2155                  $change_to->delete;
2156       } else {
2157         $error = $err_or_pkg;
2158       }
2159     } else { # change the start date only.
2160       $self->set('expire', $date);
2161       $change_to->set('start_date', $date);
2162       $error = $self->replace || $change_to->replace;
2163     }
2164     if ( $error ) {
2165       $dbh->rollback if $oldAutoCommit;
2166       return $error;
2167     } else {
2168       $dbh->commit if $oldAutoCommit;
2169       return '';
2170     }
2171   } # if $self->change_to_pkgnum
2172
2173   my $new_pkgpart = $opt->{'pkgpart'}
2174       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2175   my $new_locationnum = $opt->{'locationnum'}
2176       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2177   my $new_quantity = $opt->{'quantity'}
2178       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2179
2180   return '' unless $new_pkgpart or $new_locationnum or $new_quantity; # wouldn't do anything
2181
2182   # allow $opt->{'locationnum'} = '' to specifically set it to null
2183   # (i.e. customer default location)
2184   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2185
2186   my $new = FS::cust_pkg->new( {
2187     custnum     => $self->custnum,
2188     locationnum => $opt->{'locationnum'},
2189     start_date  => $date,
2190     map   {  $_ => ( $opt->{$_} || $self->$_() )  }
2191       qw( pkgpart quantity refnum salesnum )
2192   } );
2193   $error = $new->insert('change' => 1, 
2194                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2195   if ( !$error ) {
2196     $self->set('change_to_pkgnum', $new->pkgnum);
2197     $self->set('expire', $date);
2198     $error = $self->replace;
2199   }
2200   if ( $error ) {
2201     $dbh->rollback if $oldAutoCommit;
2202   } else {
2203     $dbh->commit if $oldAutoCommit;
2204   }
2205
2206   $error;
2207 }
2208
2209 =item abort_change
2210
2211 Cancels a future package change scheduled by C<change_later>.
2212
2213 =cut
2214
2215 sub abort_change {
2216   my $self = shift;
2217   my $pkgnum = $self->change_to_pkgnum;
2218   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2219   my $error;
2220   if ( $change_to ) {
2221     $error = $change_to->cancel || $change_to->delete;
2222     return $error if $error;
2223   }
2224   $self->set('change_to_pkgnum', '');
2225   $self->set('expire', '');
2226   $self->replace;
2227 }
2228
2229 =item set_quantity QUANTITY
2230
2231 Change the package's quantity field.  This is one of the few package properties
2232 that can safely be changed without canceling and reordering the package
2233 (because it doesn't affect tax eligibility).  Returns an error or an 
2234 empty string.
2235
2236 =cut
2237
2238 sub set_quantity {
2239   my $self = shift;
2240   $self = $self->replace_old; # just to make sure
2241   $self->quantity(shift);
2242   $self->replace;
2243 }
2244
2245 =item set_salesnum SALESNUM
2246
2247 Change the package's salesnum (sales person) field.  This is one of the few
2248 package properties that can safely be changed without canceling and reordering
2249 the package (because it doesn't affect tax eligibility).  Returns an error or
2250 an empty string.
2251
2252 =cut
2253
2254 sub set_salesnum {
2255   my $self = shift;
2256   $self = $self->replace_old; # just to make sure
2257   $self->salesnum(shift);
2258   $self->replace;
2259   # XXX this should probably reassign any credit that's already been given
2260 }
2261
2262 =item modify_charge OPTIONS
2263
2264 Change the properties of a one-time charge.  Currently the only properties
2265 that can be changed this way are those that have no impact on billing 
2266 calculations:
2267 - pkg: the package description
2268 - classnum: the package class
2269 - additional: arrayref of additional invoice details to add to this package
2270
2271 If you pass 'adjust_commission' => 1, and the classnum changes, and there are
2272 commission credits linked to this charge, they will be recalculated.
2273
2274 =cut
2275
2276 sub modify_charge {
2277   my $self = shift;
2278   my %opt = @_;
2279   my $part_pkg = $self->part_pkg;
2280   my $pkgnum = $self->pkgnum;
2281
2282   my $dbh = dbh;
2283   my $oldAutoCommit = $FS::UID::AutoCommit;
2284   local $FS::UID::AutoCommit = 0;
2285
2286   return "Can't use modify_charge except on one-time charges"
2287     unless $part_pkg->freq eq '0';
2288
2289   if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
2290     $part_pkg->set('pkg', $opt{'pkg'});
2291   }
2292
2293   my %pkg_opt = $part_pkg->options;
2294   if ( ref($opt{'additional'}) ) {
2295     delete $pkg_opt{$_} foreach grep /^additional/, keys %pkg_opt;
2296     my $i;
2297     for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
2298       $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
2299     }
2300     $pkg_opt{'additional_count'} = $i if $i > 0;
2301   }
2302
2303   my $old_classnum;
2304   if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} ) {
2305     # remember it
2306     $old_classnum = $part_pkg->classnum;
2307     $part_pkg->set('classnum', $opt{'classnum'});
2308   }
2309
2310   my $error = $part_pkg->replace( options => \%pkg_opt );
2311   return $error if $error;
2312
2313   if (defined $old_classnum) {
2314     # fix invoice grouping records
2315     my $old_catname = $old_classnum
2316                       ? FS::pkg_class->by_key($old_classnum)->categoryname
2317                       : '';
2318     my $new_catname = $opt{'classnum'}
2319                       ? $part_pkg->pkg_class->categoryname
2320                       : '';
2321     if ( $old_catname ne $new_catname ) {
2322       foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
2323         # (there should only be one...)
2324         my @display = qsearch( 'cust_bill_pkg_display', {
2325             'billpkgnum'  => $cust_bill_pkg->billpkgnum,
2326             'section'     => $old_catname,
2327         });
2328         foreach (@display) {
2329           $_->set('section', $new_catname);
2330           $error = $_->replace;
2331           if ( $error ) {
2332             $dbh->rollback if $oldAutoCommit;
2333             return $error;
2334           }
2335         }
2336       } # foreach $cust_bill_pkg
2337     }
2338
2339     if ( $opt{'adjust_commission'} ) {
2340       # fix commission credits...tricky.
2341       foreach my $cust_event ($self->cust_event) {
2342         my $part_event = $cust_event->part_event;
2343         foreach my $table (qw(sales agent)) {
2344           my $class =
2345             "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
2346           my $credit = qsearchs('cust_credit', {
2347               'eventnum' => $cust_event->eventnum,
2348           });
2349           if ( $part_event->isa($class) ) {
2350             # Yes, this results in current commission rates being applied 
2351             # retroactively to a one-time charge.  For accounting purposes 
2352             # there ought to be some kind of time limit on doing this.
2353             my $amount = $part_event->_calc_credit($self);
2354             if ( $credit and $credit->amount ne $amount ) {
2355               # Void the old credit.
2356               $error = $credit->void('Package class changed');
2357               if ( $error ) {
2358                 $dbh->rollback if $oldAutoCommit;
2359                 return "$error (adjusting commission credit)";
2360               }
2361             }
2362             # redo the event action to recreate the credit.
2363             local $@ = '';
2364             eval { $part_event->do_action( $self, $cust_event ) };
2365             if ( $@ ) {
2366               $dbh->rollback if $oldAutoCommit;
2367               return $@;
2368             }
2369           } # if $part_event->isa($class)
2370         } # foreach $table
2371       } # foreach $cust_event
2372     } # if $opt{'adjust_commission'}
2373   } # if defined $old_classnum
2374
2375   $dbh->commit if $oldAutoCommit;
2376   '';
2377 }
2378
2379
2380
2381 use Storable 'thaw';
2382 use MIME::Base64;
2383 use Data::Dumper;
2384 sub process_bulk_cust_pkg {
2385   my $job = shift;
2386   my $param = thaw(decode_base64(shift));
2387   warn Dumper($param) if $DEBUG;
2388
2389   my $old_part_pkg = qsearchs('part_pkg', 
2390                               { pkgpart => $param->{'old_pkgpart'} });
2391   my $new_part_pkg = qsearchs('part_pkg',
2392                               { pkgpart => $param->{'new_pkgpart'} });
2393   die "Must select a new package type\n" unless $new_part_pkg;
2394   #my $keep_dates = $param->{'keep_dates'} || 0;
2395   my $keep_dates = 1; # there is no good reason to turn this off
2396
2397   local $SIG{HUP} = 'IGNORE';
2398   local $SIG{INT} = 'IGNORE';
2399   local $SIG{QUIT} = 'IGNORE';
2400   local $SIG{TERM} = 'IGNORE';
2401   local $SIG{TSTP} = 'IGNORE';
2402   local $SIG{PIPE} = 'IGNORE';
2403
2404   my $oldAutoCommit = $FS::UID::AutoCommit;
2405   local $FS::UID::AutoCommit = 0;
2406   my $dbh = dbh;
2407
2408   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
2409
2410   my $i = 0;
2411   foreach my $old_cust_pkg ( @cust_pkgs ) {
2412     $i++;
2413     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
2414     if ( $old_cust_pkg->getfield('cancel') ) {
2415       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
2416         $old_cust_pkg->pkgnum."\n"
2417         if $DEBUG;
2418       next;
2419     }
2420     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
2421       if $DEBUG;
2422     my $error = $old_cust_pkg->change(
2423       'pkgpart'     => $param->{'new_pkgpart'},
2424       'keep_dates'  => $keep_dates
2425     );
2426     if ( !ref($error) ) { # change returns the cust_pkg on success
2427       $dbh->rollback;
2428       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
2429     }
2430   }
2431   $dbh->commit if $oldAutoCommit;
2432   return;
2433 }
2434
2435 =item last_bill
2436
2437 Returns the last bill date, or if there is no last bill date, the setup date.
2438 Useful for billing metered services.
2439
2440 =cut
2441
2442 sub last_bill {
2443   my $self = shift;
2444   return $self->setfield('last_bill', $_[0]) if @_;
2445   return $self->getfield('last_bill') if $self->getfield('last_bill');
2446   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
2447                                                   'edate'  => $self->bill,  } );
2448   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
2449 }
2450
2451 =item last_cust_pkg_reason ACTION
2452
2453 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
2454 Returns false if there is no reason or the package is not currenly ACTION'd
2455 ACTION is one of adjourn, susp, cancel, or expire.
2456
2457 =cut
2458
2459 sub last_cust_pkg_reason {
2460   my ( $self, $action ) = ( shift, shift );
2461   my $date = $self->get($action);
2462   qsearchs( {
2463               'table' => 'cust_pkg_reason',
2464               'hashref' => { 'pkgnum' => $self->pkgnum,
2465                              'action' => substr(uc($action), 0, 1),
2466                              'date'   => $date,
2467                            },
2468               'order_by' => 'ORDER BY num DESC LIMIT 1',
2469            } );
2470 }
2471
2472 =item last_reason ACTION
2473
2474 Returns the most recent ACTION FS::reason associated with the package.
2475 Returns false if there is no reason or the package is not currenly ACTION'd
2476 ACTION is one of adjourn, susp, cancel, or expire.
2477
2478 =cut
2479
2480 sub last_reason {
2481   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
2482   $cust_pkg_reason->reason
2483     if $cust_pkg_reason;
2484 }
2485
2486 =item part_pkg
2487
2488 Returns the definition for this billing item, as an FS::part_pkg object (see
2489 L<FS::part_pkg>).
2490
2491 =cut
2492
2493 sub part_pkg {
2494   my $self = shift;
2495   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
2496   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
2497   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
2498 }
2499
2500 =item old_cust_pkg
2501
2502 Returns the cancelled package this package was changed from, if any.
2503
2504 =cut
2505
2506 sub old_cust_pkg {
2507   my $self = shift;
2508   return '' unless $self->change_pkgnum;
2509   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
2510 }
2511
2512 =item change_cust_main
2513
2514 Returns the customter this package was detached to, if any.
2515
2516 =cut
2517
2518 sub change_cust_main {
2519   my $self = shift;
2520   return '' unless $self->change_custnum;
2521   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
2522 }
2523
2524 =item calc_setup
2525
2526 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
2527 item.
2528
2529 =cut
2530
2531 sub calc_setup {
2532   my $self = shift;
2533   $self->part_pkg->calc_setup($self, @_);
2534 }
2535
2536 =item calc_recur
2537
2538 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
2539 item.
2540
2541 =cut
2542
2543 sub calc_recur {
2544   my $self = shift;
2545   $self->part_pkg->calc_recur($self, @_);
2546 }
2547
2548 =item base_setup
2549
2550 Calls the I<base_setup> of the FS::part_pkg object associated with this billing
2551 item.
2552
2553 =cut
2554
2555 sub base_setup {
2556   my $self = shift;
2557   $self->part_pkg->base_setup($self, @_);
2558 }
2559
2560 =item base_recur
2561
2562 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
2563 item.
2564
2565 =cut
2566
2567 sub base_recur {
2568   my $self = shift;
2569   $self->part_pkg->base_recur($self, @_);
2570 }
2571
2572 =item calc_remain
2573
2574 Calls the I<calc_remain> of the FS::part_pkg object associated with this
2575 billing item.
2576
2577 =cut
2578
2579 sub calc_remain {
2580   my $self = shift;
2581   $self->part_pkg->calc_remain($self, @_);
2582 }
2583
2584 =item calc_cancel
2585
2586 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
2587 billing item.
2588
2589 =cut
2590
2591 sub calc_cancel {
2592   my $self = shift;
2593   $self->part_pkg->calc_cancel($self, @_);
2594 }
2595
2596 =item cust_bill_pkg
2597
2598 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
2599
2600 =cut
2601
2602 sub cust_bill_pkg {
2603   my $self = shift;
2604   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
2605 }
2606
2607 =item cust_pkg_detail [ DETAILTYPE ]
2608
2609 Returns any customer package details for this package (see
2610 L<FS::cust_pkg_detail>).
2611
2612 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2613
2614 =cut
2615
2616 sub cust_pkg_detail {
2617   my $self = shift;
2618   my %hash = ( 'pkgnum' => $self->pkgnum );
2619   $hash{detailtype} = shift if @_;
2620   qsearch({
2621     'table'    => 'cust_pkg_detail',
2622     'hashref'  => \%hash,
2623     'order_by' => 'ORDER BY weight, pkgdetailnum',
2624   });
2625 }
2626
2627 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
2628
2629 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
2630
2631 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2632
2633 If there is an error, returns the error, otherwise returns false.
2634
2635 =cut
2636
2637 sub set_cust_pkg_detail {
2638   my( $self, $detailtype, @details ) = @_;
2639
2640   local $SIG{HUP} = 'IGNORE';
2641   local $SIG{INT} = 'IGNORE';
2642   local $SIG{QUIT} = 'IGNORE';
2643   local $SIG{TERM} = 'IGNORE';
2644   local $SIG{TSTP} = 'IGNORE';
2645   local $SIG{PIPE} = 'IGNORE';
2646
2647   my $oldAutoCommit = $FS::UID::AutoCommit;
2648   local $FS::UID::AutoCommit = 0;
2649   my $dbh = dbh;
2650
2651   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
2652     my $error = $current->delete;
2653     if ( $error ) {
2654       $dbh->rollback if $oldAutoCommit;
2655       return "error removing old detail: $error";
2656     }
2657   }
2658
2659   foreach my $detail ( @details ) {
2660     my $cust_pkg_detail = new FS::cust_pkg_detail {
2661       'pkgnum'     => $self->pkgnum,
2662       'detailtype' => $detailtype,
2663       'detail'     => $detail,
2664     };
2665     my $error = $cust_pkg_detail->insert;
2666     if ( $error ) {
2667       $dbh->rollback if $oldAutoCommit;
2668       return "error adding new detail: $error";
2669     }
2670
2671   }
2672
2673   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2674   '';
2675
2676 }
2677
2678 =item cust_event
2679
2680 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
2681
2682 =cut
2683
2684 #false laziness w/cust_bill.pm
2685 sub cust_event {
2686   my $self = shift;
2687   qsearch({
2688     'table'     => 'cust_event',
2689     'addl_from' => 'JOIN part_event USING ( eventpart )',
2690     'hashref'   => { 'tablenum' => $self->pkgnum },
2691     'extra_sql' => " AND eventtable = 'cust_pkg' ",
2692   });
2693 }
2694
2695 =item num_cust_event
2696
2697 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
2698
2699 =cut
2700
2701 #false laziness w/cust_bill.pm
2702 sub num_cust_event {
2703   my $self = shift;
2704   my $sql =
2705     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
2706     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
2707   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
2708   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
2709   $sth->fetchrow_arrayref->[0];
2710 }
2711
2712 =item part_pkg_currency_option OPTIONNAME
2713
2714 Returns a two item list consisting of the currency of this customer, if any,
2715 and a value for the provided option.  If the customer has a currency, the value
2716 is the option value the given name and the currency (see
2717 L<FS::part_pkg_currency>).  Otherwise, if the customer has no currency, is the
2718 regular option value for the given name (see L<FS::part_pkg_option>).
2719
2720 =cut
2721
2722 sub part_pkg_currency_option {
2723   my( $self, $optionname ) = @_;
2724   my $part_pkg = $self->part_pkg;
2725   if ( my $currency = $self->cust_main->currency ) {
2726     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
2727   } else {
2728     ('', $part_pkg->option($optionname) );
2729   }
2730 }
2731
2732 =item cust_svc [ SVCPART ] (old, deprecated usage)
2733
2734 =item cust_svc [ OPTION => VALUE ... ] (current usage)
2735
2736 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
2737
2738 Returns the services for this package, as FS::cust_svc objects (see
2739 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
2740 spcififed, returns only the matching services.
2741
2742 As an optimization, use the cust_svc_unsorted version if you are not displaying
2743 the results.
2744
2745 =cut
2746
2747 sub cust_svc {
2748   my $self = shift;
2749   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2750   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref );
2751 }
2752
2753 sub cust_svc_unsorted {
2754   my $self = shift;
2755   @{ $self->cust_svc_unsorted_arrayref };
2756 }
2757
2758 sub cust_svc_unsorted_arrayref {
2759   my $self = shift;
2760
2761   return () unless $self->num_cust_svc(@_);
2762
2763   my %opt = ();
2764   if ( @_ && $_[0] =~ /^\d+/ ) {
2765     $opt{svcpart} = shift;
2766   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2767     %opt = %{ $_[0] };
2768   } elsif ( @_ ) {
2769     %opt = @_;
2770   }
2771
2772   my %search = (
2773     'table'   => 'cust_svc',
2774     'hashref' => { 'pkgnum' => $self->pkgnum },
2775   );
2776   if ( $opt{svcpart} ) {
2777     $search{hashref}->{svcpart} = $opt{'svcpart'};
2778   }
2779   if ( $opt{'svcdb'} ) {
2780     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2781     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2782   }
2783
2784   [ qsearch(\%search) ];
2785
2786 }
2787
2788 =item overlimit [ SVCPART ]
2789
2790 Returns the services for this package which have exceeded their
2791 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
2792 is specified, return only the matching services.
2793
2794 =cut
2795
2796 sub overlimit {
2797   my $self = shift;
2798   return () unless $self->num_cust_svc(@_);
2799   grep { $_->overlimit } $self->cust_svc(@_);
2800 }
2801
2802 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2803
2804 Returns historical services for this package created before END TIMESTAMP and
2805 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2806 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
2807 I<pkg_svc.hidden> flag will be omitted.
2808
2809 =cut
2810
2811 sub h_cust_svc {
2812   my $self = shift;
2813   warn "$me _h_cust_svc called on $self\n"
2814     if $DEBUG;
2815
2816   my ($end, $start, $mode) = @_;
2817   my @cust_svc = $self->_sort_cust_svc(
2818     [ qsearch( 'h_cust_svc',
2819       { 'pkgnum' => $self->pkgnum, },  
2820       FS::h_cust_svc->sql_h_search(@_),  
2821     ) ]
2822   );
2823   if ( defined($mode) && $mode eq 'I' ) {
2824     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2825     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2826   } else {
2827     return @cust_svc;
2828   }
2829 }
2830
2831 sub _sort_cust_svc {
2832   my( $self, $arrayref ) = @_;
2833
2834   my $sort =
2835     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
2836
2837   my %pkg_svc = map { $_->svcpart => $_ }
2838                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
2839
2840   map  { $_->[0] }
2841   sort $sort
2842   map {
2843         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
2844         [ $_,
2845           $pkg_svc ? $pkg_svc->primary_svc : '',
2846           $pkg_svc ? $pkg_svc->quantity : 0,
2847         ];
2848       }
2849   @$arrayref;
2850
2851 }
2852
2853 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2854
2855 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2856
2857 Returns the number of services for this package.  Available options are svcpart
2858 and svcdb.  If either is spcififed, returns only the matching services.
2859
2860 =cut
2861
2862 sub num_cust_svc {
2863   my $self = shift;
2864
2865   return $self->{'_num_cust_svc'}
2866     if !scalar(@_)
2867        && exists($self->{'_num_cust_svc'})
2868        && $self->{'_num_cust_svc'} =~ /\d/;
2869
2870   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2871     if $DEBUG > 2;
2872
2873   my %opt = ();
2874   if ( @_ && $_[0] =~ /^\d+/ ) {
2875     $opt{svcpart} = shift;
2876   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2877     %opt = %{ $_[0] };
2878   } elsif ( @_ ) {
2879     %opt = @_;
2880   }
2881
2882   my $select = 'SELECT COUNT(*) FROM cust_svc ';
2883   my $where = ' WHERE pkgnum = ? ';
2884   my @param = ($self->pkgnum);
2885
2886   if ( $opt{'svcpart'} ) {
2887     $where .= ' AND svcpart = ? ';
2888     push @param, $opt{'svcpart'};
2889   }
2890   if ( $opt{'svcdb'} ) {
2891     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2892     $where .= ' AND svcdb = ? ';
2893     push @param, $opt{'svcdb'};
2894   }
2895
2896   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
2897   $sth->execute(@param) or die $sth->errstr;
2898   $sth->fetchrow_arrayref->[0];
2899 }
2900
2901 =item available_part_svc 
2902
2903 Returns a list of FS::part_svc objects representing services included in this
2904 package but not yet provisioned.  Each FS::part_svc object also has an extra
2905 field, I<num_avail>, which specifies the number of available services.
2906
2907 =cut
2908
2909 sub available_part_svc {
2910   my $self = shift;
2911
2912   my $pkg_quantity = $self->quantity || 1;
2913
2914   grep { $_->num_avail > 0 }
2915     map {
2916           my $part_svc = $_->part_svc;
2917           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2918             $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
2919
2920           # more evil encapsulation breakage
2921           if($part_svc->{'Hash'}{'num_avail'} > 0) {
2922             my @exports = $part_svc->part_export_did;
2923             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2924           }
2925
2926           $part_svc;
2927         }
2928       $self->part_pkg->pkg_svc;
2929 }
2930
2931 =item part_svc [ OPTION => VALUE ... ]
2932
2933 Returns a list of FS::part_svc objects representing provisioned and available
2934 services included in this package.  Each FS::part_svc object also has the
2935 following extra fields:
2936
2937 =over 4
2938
2939 =item num_cust_svc  (count)
2940
2941 =item num_avail     (quantity - count)
2942
2943 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2944
2945 =back
2946
2947 Accepts one option: summarize_size.  If specified and non-zero, will omit the
2948 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2949 greater.
2950
2951 =cut
2952
2953 #svcnum
2954 #label -> ($cust_svc->label)[1]
2955
2956 sub part_svc {
2957   my $self = shift;
2958   my %opt = @_;
2959
2960   my $pkg_quantity = $self->quantity || 1;
2961
2962   #XXX some sort of sort order besides numeric by svcpart...
2963   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2964     my $pkg_svc = $_;
2965     my $part_svc = $pkg_svc->part_svc;
2966     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2967     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2968     $part_svc->{'Hash'}{'num_avail'}    =
2969       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
2970     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2971         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2972       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2973           && $num_cust_svc >= $opt{summarize_size};
2974     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2975     $part_svc;
2976   } $self->part_pkg->pkg_svc;
2977
2978   #extras
2979   push @part_svc, map {
2980     my $part_svc = $_;
2981     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2982     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2983     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
2984     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2985       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2986     $part_svc;
2987   } $self->extra_part_svc;
2988
2989   @part_svc;
2990
2991 }
2992
2993 =item extra_part_svc
2994
2995 Returns a list of FS::part_svc objects corresponding to services in this
2996 package which are still provisioned but not (any longer) available in the
2997 package definition.
2998
2999 =cut
3000
3001 sub extra_part_svc {
3002   my $self = shift;
3003
3004   my $pkgnum  = $self->pkgnum;
3005   #my $pkgpart = $self->pkgpart;
3006
3007 #  qsearch( {
3008 #    'table'     => 'part_svc',
3009 #    'hashref'   => {},
3010 #    'extra_sql' =>
3011 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3012 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3013 #                       AND pkg_svc.pkgpart = ?
3014 #                       AND quantity > 0 
3015 #                 )
3016 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3017 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3018 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3019 #                       AND pkgnum = ?
3020 #                 )",
3021 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3022 #  } );
3023
3024 #seems to benchmark slightly faster... (or did?)
3025
3026   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3027   my $pkgparts = join(',', @pkgparts);
3028
3029   qsearch( {
3030     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3031     #MySQL doesn't grok DISINCT ON
3032     'select'      => 'DISTINCT part_svc.*',
3033     'table'       => 'part_svc',
3034     'addl_from'   =>
3035       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3036                                AND pkg_svc.pkgpart IN ($pkgparts)
3037                                AND quantity > 0
3038                              )
3039        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3040        LEFT JOIN cust_pkg USING ( pkgnum )
3041       ",
3042     'hashref'     => {},
3043     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3044     'extra_param' => [ [$self->pkgnum=>'int'] ],
3045   } );
3046 }
3047
3048 =item status
3049
3050 Returns a short status string for this package, currently:
3051
3052 =over 4
3053
3054 =item not yet billed
3055
3056 =item one-time charge
3057
3058 =item active
3059
3060 =item suspended
3061
3062 =item cancelled
3063
3064 =back
3065
3066 =cut
3067
3068 sub status {
3069   my $self = shift;
3070
3071   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3072
3073   return 'cancelled' if $self->get('cancel');
3074   return 'suspended' if $self->susp;
3075   return 'not yet billed' unless $self->setup;
3076   return 'one-time charge' if $freq =~ /^(0|$)/;
3077   return 'active';
3078 }
3079
3080 =item ucfirst_status
3081
3082 Returns the status with the first character capitalized.
3083
3084 =cut
3085
3086 sub ucfirst_status {
3087   ucfirst(shift->status);
3088 }
3089
3090 =item statuses
3091
3092 Class method that returns the list of possible status strings for packages
3093 (see L<the status method|/status>).  For example:
3094
3095   @statuses = FS::cust_pkg->statuses();
3096
3097 =cut
3098
3099 tie my %statuscolor, 'Tie::IxHash', 
3100   'not yet billed'  => '009999', #teal? cyan?
3101   'one-time charge' => '000000',
3102   'active'          => '00CC00',
3103   'suspended'       => 'FF9900',
3104   'cancelled'       => 'FF0000',
3105 ;
3106
3107 sub statuses {
3108   my $self = shift; #could be class...
3109   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3110   #                                    # mayble split btw one-time vs. recur
3111     keys %statuscolor;
3112 }
3113
3114 =item statuscolor
3115
3116 Returns a hex triplet color string for this package's status.
3117
3118 =cut
3119
3120 sub statuscolor {
3121   my $self = shift;
3122   $statuscolor{$self->status};
3123 }
3124
3125 =item pkg_label
3126
3127 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3128 "pkg - comment" depending on user preference).
3129
3130 =cut
3131
3132 sub pkg_label {
3133   my $self = shift;
3134   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
3135   $label = $self->pkgnum. ": $label"
3136     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3137   $label;
3138 }
3139
3140 =item pkg_label_long
3141
3142 Returns a long label for this package, adding the primary service's label to
3143 pkg_label.
3144
3145 =cut
3146
3147 sub pkg_label_long {
3148   my $self = shift;
3149   my $label = $self->pkg_label;
3150   my $cust_svc = $self->primary_cust_svc;
3151   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3152   $label;
3153 }
3154
3155 =item pkg_locale
3156
3157 Returns a customer-localized label for this package.
3158
3159 =cut
3160
3161 sub pkg_locale {
3162   my $self = shift;
3163   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3164 }
3165
3166 =item primary_cust_svc
3167
3168 Returns a primary service (as FS::cust_svc object) if one can be identified.
3169
3170 =cut
3171
3172 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3173
3174 sub primary_cust_svc {
3175   my $self = shift;
3176
3177   my @cust_svc = $self->cust_svc;
3178
3179   return '' unless @cust_svc; #no serivces - irrelevant then
3180   
3181   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3182
3183   # primary service as specified in the package definition
3184   # or exactly one service definition with quantity one
3185   my $svcpart = $self->part_pkg->svcpart;
3186   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3187   return $cust_svc[0] if scalar(@cust_svc) == 1;
3188
3189   #couldn't identify one thing..
3190   return '';
3191 }
3192
3193 =item labels
3194
3195 Returns a list of lists, calling the label method for all services
3196 (see L<FS::cust_svc>) of this billing item.
3197
3198 =cut
3199
3200 sub labels {
3201   my $self = shift;
3202   map { [ $_->label ] } $self->cust_svc;
3203 }
3204
3205 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3206
3207 Like the labels method, but returns historical information on services that
3208 were active as of END_TIMESTAMP and (optionally) not cancelled before
3209 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3210 I<pkg_svc.hidden> flag will be omitted.
3211
3212 Returns a list of lists, calling the label method for all (historical) services
3213 (see L<FS::h_cust_svc>) of this billing item.
3214
3215 =cut
3216
3217 sub h_labels {
3218   my $self = shift;
3219   warn "$me _h_labels called on $self\n"
3220     if $DEBUG;
3221   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3222 }
3223
3224 =item labels_short
3225
3226 Like labels, except returns a simple flat list, and shortens long
3227 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3228 identical services to one line that lists the service label and the number of
3229 individual services rather than individual items.
3230
3231 =cut
3232
3233 sub labels_short {
3234   shift->_labels_short( 'labels', @_ );
3235 }
3236
3237 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3238
3239 Like h_labels, except returns a simple flat list, and shortens long
3240 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3241 identical services to one line that lists the service label and the number of
3242 individual services rather than individual items.
3243
3244 =cut
3245
3246 sub h_labels_short {
3247   shift->_labels_short( 'h_labels', @_ );
3248 }
3249
3250 sub _labels_short {
3251   my( $self, $method ) = ( shift, shift );
3252
3253   warn "$me _labels_short called on $self with $method method\n"
3254     if $DEBUG;
3255
3256   my $conf = new FS::Conf;
3257   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3258
3259   warn "$me _labels_short populating \%labels\n"
3260     if $DEBUG;
3261
3262   my %labels;
3263   #tie %labels, 'Tie::IxHash';
3264   push @{ $labels{$_->[0]} }, $_->[1]
3265     foreach $self->$method(@_);
3266
3267   warn "$me _labels_short populating \@labels\n"
3268     if $DEBUG;
3269
3270   my @labels;
3271   foreach my $label ( keys %labels ) {
3272     my %seen = ();
3273     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3274     my $num = scalar(@values);
3275     warn "$me _labels_short $num items for $label\n"
3276       if $DEBUG;
3277
3278     if ( $num > $max_same_services ) {
3279       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3280         if $DEBUG;
3281       push @labels, "$label ($num)";
3282     } else {
3283       if ( $conf->exists('cust_bill-consolidate_services') ) {
3284         warn "$me _labels_short   consolidating services\n"
3285           if $DEBUG;
3286         # push @labels, "$label: ". join(', ', @values);
3287         while ( @values ) {
3288           my $detail = "$label: ";
3289           $detail .= shift(@values). ', '
3290             while @values
3291                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3292           $detail =~ s/, $//;
3293           push @labels, $detail;
3294         }
3295         warn "$me _labels_short   done consolidating services\n"
3296           if $DEBUG;
3297       } else {
3298         warn "$me _labels_short   adding service data\n"
3299           if $DEBUG;
3300         push @labels, map { "$label: $_" } @values;
3301       }
3302     }
3303   }
3304
3305  @labels;
3306
3307 }
3308
3309 =item cust_main
3310
3311 Returns the parent customer object (see L<FS::cust_main>).
3312
3313 =cut
3314
3315 sub cust_main {
3316   my $self = shift;
3317   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
3318 }
3319
3320 =item balance
3321
3322 Returns the balance for this specific package, when using
3323 experimental package balance.
3324
3325 =cut
3326
3327 sub balance {
3328   my $self = shift;
3329   $self->cust_main->balance_pkgnum( $self->pkgnum );
3330 }
3331
3332 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3333
3334 =item cust_location
3335
3336 Returns the location object, if any (see L<FS::cust_location>).
3337
3338 =item cust_location_or_main
3339
3340 If this package is associated with a location, returns the locaiton (see
3341 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3342
3343 =item location_label [ OPTION => VALUE ... ]
3344
3345 Returns the label of the location object (see L<FS::cust_location>).
3346
3347 =cut
3348
3349 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3350
3351 =item tax_locationnum
3352
3353 Returns the foreign key to a L<FS::cust_location> object for calculating  
3354 tax on this package, as determined by the C<tax-pkg_address> and 
3355 C<tax-ship_address> configuration flags.
3356
3357 =cut
3358
3359 sub tax_locationnum {
3360   my $self = shift;
3361   my $conf = FS::Conf->new;
3362   if ( $conf->exists('tax-pkg_address') ) {
3363     return $self->locationnum;
3364   }
3365   elsif ( $conf->exists('tax-ship_address') ) {
3366     return $self->cust_main->ship_locationnum;
3367   }
3368   else {
3369     return $self->cust_main->bill_locationnum;
3370   }
3371 }
3372
3373 =item tax_location
3374
3375 Returns the L<FS::cust_location> object for tax_locationnum.
3376
3377 =cut
3378
3379 sub tax_location {
3380   my $self = shift;
3381   FS::cust_location->by_key( $self->tax_locationnum )
3382 }
3383
3384 =item seconds_since TIMESTAMP
3385
3386 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3387 package have been online since TIMESTAMP, according to the session monitor.
3388
3389 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3390 L<Time::Local> and L<Date::Parse> for conversion functions.
3391
3392 =cut
3393
3394 sub seconds_since {
3395   my($self, $since) = @_;
3396   my $seconds = 0;
3397
3398   foreach my $cust_svc (
3399     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3400   ) {
3401     $seconds += $cust_svc->seconds_since($since);
3402   }
3403
3404   $seconds;
3405
3406 }
3407
3408 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3409
3410 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3411 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3412 (exclusive).
3413
3414 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3415 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3416 functions.
3417
3418
3419 =cut
3420
3421 sub seconds_since_sqlradacct {
3422   my($self, $start, $end) = @_;
3423
3424   my $seconds = 0;
3425
3426   foreach my $cust_svc (
3427     grep {
3428       my $part_svc = $_->part_svc;
3429       $part_svc->svcdb eq 'svc_acct'
3430         && scalar($part_svc->part_export_usage);
3431     } $self->cust_svc
3432   ) {
3433     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3434   }
3435
3436   $seconds;
3437
3438 }
3439
3440 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
3441
3442 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
3443 in this package for sessions ending between TIMESTAMP_START (inclusive) and
3444 TIMESTAMP_END
3445 (exclusive).
3446
3447 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3448 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3449 functions.
3450
3451 =cut
3452
3453 sub attribute_since_sqlradacct {
3454   my($self, $start, $end, $attrib) = @_;
3455
3456   my $sum = 0;
3457
3458   foreach my $cust_svc (
3459     grep {
3460       my $part_svc = $_->part_svc;
3461       $part_svc->svcdb eq 'svc_acct'
3462         && scalar($part_svc->part_export_usage);
3463     } $self->cust_svc
3464   ) {
3465     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3466   }
3467
3468   $sum;
3469
3470 }
3471
3472 =item quantity
3473
3474 =cut
3475
3476 sub quantity {
3477   my( $self, $value ) = @_;
3478   if ( defined($value) ) {
3479     $self->setfield('quantity', $value);
3480   }
3481   $self->getfield('quantity') || 1;
3482 }
3483
3484 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3485
3486 Transfers as many services as possible from this package to another package.
3487
3488 The destination package can be specified by pkgnum by passing an FS::cust_pkg
3489 object.  The destination package must already exist.
3490
3491 Services are moved only if the destination allows services with the correct
3492 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
3493 this option with caution!  No provision is made for export differences
3494 between the old and new service definitions.  Probably only should be used
3495 when your exports for all service definitions of a given svcdb are identical.
3496 (attempt a transfer without it first, to move all possible svcpart-matching
3497 services)
3498
3499 Any services that can't be moved remain in the original package.
3500
3501 Returns an error, if there is one; otherwise, returns the number of services 
3502 that couldn't be moved.
3503
3504 =cut
3505
3506 sub transfer {
3507   my ($self, $dest_pkgnum, %opt) = @_;
3508
3509   my $remaining = 0;
3510   my $dest;
3511   my %target;
3512
3513   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
3514     $dest = $dest_pkgnum;
3515     $dest_pkgnum = $dest->pkgnum;
3516   } else {
3517     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
3518   }
3519
3520   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
3521
3522   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
3523     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
3524   }
3525
3526   foreach my $cust_svc ($dest->cust_svc) {
3527     $target{$cust_svc->svcpart}--;
3528   }
3529
3530   my %svcpart2svcparts = ();
3531   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3532     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
3533     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
3534       next if exists $svcpart2svcparts{$svcpart};
3535       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
3536       $svcpart2svcparts{$svcpart} = [
3537         map  { $_->[0] }
3538         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
3539         map {
3540               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
3541                                                    'svcpart' => $_          } );
3542               [ $_,
3543                 $pkg_svc ? $pkg_svc->primary_svc : '',
3544                 $pkg_svc ? $pkg_svc->quantity : 0,
3545               ];
3546             }
3547
3548         grep { $_ != $svcpart }
3549         map  { $_->svcpart }
3550         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
3551       ];
3552       warn "alternates for svcpart $svcpart: ".
3553            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
3554         if $DEBUG;
3555     }
3556   }
3557
3558   foreach my $cust_svc ($self->cust_svc) {
3559     if($target{$cust_svc->svcpart} > 0
3560        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
3561       $target{$cust_svc->svcpart}--;
3562       my $new = new FS::cust_svc { $cust_svc->hash };
3563       $new->pkgnum($dest_pkgnum);
3564       my $error = $new->replace($cust_svc);
3565       return $error if $error;
3566     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3567       if ( $DEBUG ) {
3568         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
3569         warn "alternates to consider: ".
3570              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
3571       }
3572       my @alternate = grep {
3573                              warn "considering alternate svcpart $_: ".
3574                                   "$target{$_} available in new package\n"
3575                                if $DEBUG;
3576                              $target{$_} > 0;
3577                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
3578       if ( @alternate ) {
3579         warn "alternate(s) found\n" if $DEBUG;
3580         my $change_svcpart = $alternate[0];
3581         $target{$change_svcpart}--;
3582         my $new = new FS::cust_svc { $cust_svc->hash };
3583         $new->svcpart($change_svcpart);
3584         $new->pkgnum($dest_pkgnum);
3585         my $error = $new->replace($cust_svc);
3586         return $error if $error;
3587       } else {
3588         $remaining++;
3589       }
3590     } else {
3591       $remaining++
3592     }
3593   }
3594   return $remaining;
3595 }
3596
3597 =item grab_svcnums SVCNUM, SVCNUM ...
3598
3599 Change the pkgnum for the provided services to this packages.  If there is an
3600 error, returns the error, otherwise returns false.
3601
3602 =cut
3603
3604 sub grab_svcnums {
3605   my $self = shift;
3606   my @svcnum = @_;
3607
3608   local $SIG{HUP} = 'IGNORE';
3609   local $SIG{INT} = 'IGNORE';
3610   local $SIG{QUIT} = 'IGNORE';
3611   local $SIG{TERM} = 'IGNORE';
3612   local $SIG{TSTP} = 'IGNORE';
3613   local $SIG{PIPE} = 'IGNORE';
3614
3615   my $oldAutoCommit = $FS::UID::AutoCommit;
3616   local $FS::UID::AutoCommit = 0;
3617   my $dbh = dbh;
3618
3619   foreach my $svcnum (@svcnum) {
3620     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
3621       $dbh->rollback if $oldAutoCommit;
3622       return "unknown svcnum $svcnum";
3623     };
3624     $cust_svc->pkgnum( $self->pkgnum );
3625     my $error = $cust_svc->replace;
3626     if ( $error ) {
3627       $dbh->rollback if $oldAutoCommit;
3628       return $error;
3629     }
3630   }
3631
3632   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3633   '';
3634
3635 }
3636
3637 =item reexport
3638
3639 This method is deprecated.  See the I<depend_jobnum> option to the insert and
3640 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
3641
3642 =cut
3643
3644 #looks like this is still used by the order_pkg and change_pkg methods in
3645 # ClientAPI/MyAccount, need to look into those before removing
3646 sub reexport {
3647   my $self = shift;
3648
3649   local $SIG{HUP} = 'IGNORE';
3650   local $SIG{INT} = 'IGNORE';
3651   local $SIG{QUIT} = 'IGNORE';
3652   local $SIG{TERM} = 'IGNORE';
3653   local $SIG{TSTP} = 'IGNORE';
3654   local $SIG{PIPE} = 'IGNORE';
3655
3656   my $oldAutoCommit = $FS::UID::AutoCommit;
3657   local $FS::UID::AutoCommit = 0;
3658   my $dbh = dbh;
3659
3660   foreach my $cust_svc ( $self->cust_svc ) {
3661     #false laziness w/svc_Common::insert
3662     my $svc_x = $cust_svc->svc_x;
3663     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
3664       my $error = $part_export->export_insert($svc_x);
3665       if ( $error ) {
3666         $dbh->rollback if $oldAutoCommit;
3667         return $error;
3668       }
3669     }
3670   }
3671
3672   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3673   '';
3674
3675 }
3676
3677 =item export_pkg_change OLD_CUST_PKG
3678
3679 Calls the "pkg_change" export action for all services attached to this package.
3680
3681 =cut
3682
3683 sub export_pkg_change {
3684   my( $self, $old )  = ( shift, shift );
3685
3686   local $SIG{HUP} = 'IGNORE';
3687   local $SIG{INT} = 'IGNORE';
3688   local $SIG{QUIT} = 'IGNORE';
3689   local $SIG{TERM} = 'IGNORE';
3690   local $SIG{TSTP} = 'IGNORE';
3691   local $SIG{PIPE} = 'IGNORE';
3692
3693   my $oldAutoCommit = $FS::UID::AutoCommit;
3694   local $FS::UID::AutoCommit = 0;
3695   my $dbh = dbh;
3696
3697   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
3698     my $error = $svc_x->export('pkg_change', $self, $old);
3699     if ( $error ) {
3700       $dbh->rollback if $oldAutoCommit;
3701       return $error;
3702     }
3703   }
3704
3705   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3706   '';
3707
3708 }
3709
3710 =item insert_reason
3711
3712 Associates this package with a (suspension or cancellation) reason (see
3713 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
3714 L<FS::reason>).
3715
3716 Available options are:
3717
3718 =over 4
3719
3720 =item reason
3721
3722 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.
3723
3724 =item reason_otaker
3725
3726 the access_user (see L<FS::access_user>) providing the reason
3727
3728 =item date
3729
3730 a unix timestamp 
3731
3732 =item action
3733
3734 the action (cancel, susp, adjourn, expire) associated with the reason
3735
3736 =back
3737
3738 If there is an error, returns the error, otherwise returns false.
3739
3740 =cut
3741
3742 sub insert_reason {
3743   my ($self, %options) = @_;
3744
3745   my $otaker = $options{reason_otaker} ||
3746                $FS::CurrentUser::CurrentUser->username;
3747
3748   my $reasonnum;
3749   if ( $options{'reason'} =~ /^(\d+)$/ ) {
3750
3751     $reasonnum = $1;
3752
3753   } elsif ( ref($options{'reason'}) ) {
3754   
3755     return 'Enter a new reason (or select an existing one)'
3756       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
3757
3758     my $reason = new FS::reason({
3759       'reason_type' => $options{'reason'}->{'typenum'},
3760       'reason'      => $options{'reason'}->{'reason'},
3761     });
3762     my $error = $reason->insert;
3763     return $error if $error;
3764
3765     $reasonnum = $reason->reasonnum;
3766
3767   } else {
3768     return "Unparsable reason: ". $options{'reason'};
3769   }
3770
3771   my $cust_pkg_reason =
3772     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
3773                               'reasonnum' => $reasonnum, 
3774                               'otaker'    => $otaker,
3775                               'action'    => substr(uc($options{'action'}),0,1),
3776                               'date'      => $options{'date'}
3777                                                ? $options{'date'}
3778                                                : time,
3779                             });
3780
3781   $cust_pkg_reason->insert;
3782 }
3783
3784 =item insert_discount
3785
3786 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
3787 inserting a new discount on the fly (see L<FS::discount>).
3788
3789 Available options are:
3790
3791 =over 4
3792
3793 =item discountnum
3794
3795 =back
3796
3797 If there is an error, returns the error, otherwise returns false.
3798
3799 =cut
3800
3801 sub insert_discount {
3802   #my ($self, %options) = @_;
3803   my $self = shift;
3804
3805   my $cust_pkg_discount = new FS::cust_pkg_discount {
3806     'pkgnum'      => $self->pkgnum,
3807     'discountnum' => $self->discountnum,
3808     'months_used' => 0,
3809     'end_date'    => '', #XXX
3810     #for the create a new discount case
3811     '_type'       => $self->discountnum__type,
3812     'amount'      => $self->discountnum_amount,
3813     'percent'     => $self->discountnum_percent,
3814     'months'      => $self->discountnum_months,
3815     'setup'      => $self->discountnum_setup,
3816     #'disabled'    => $self->discountnum_disabled,
3817   };
3818
3819   $cust_pkg_discount->insert;
3820 }
3821
3822 =item set_usage USAGE_VALUE_HASHREF 
3823
3824 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3825 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3826 upbytes, downbytes, and totalbytes are appropriate keys.
3827
3828 All svc_accts which are part of this package have their values reset.
3829
3830 =cut
3831
3832 sub set_usage {
3833   my ($self, $valueref, %opt) = @_;
3834
3835   #only svc_acct can set_usage for now
3836   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3837     my $svc_x = $cust_svc->svc_x;
3838     $svc_x->set_usage($valueref, %opt)
3839       if $svc_x->can("set_usage");
3840   }
3841 }
3842
3843 =item recharge USAGE_VALUE_HASHREF 
3844
3845 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3846 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3847 upbytes, downbytes, and totalbytes are appropriate keys.
3848
3849 All svc_accts which are part of this package have their values incremented.
3850
3851 =cut
3852
3853 sub recharge {
3854   my ($self, $valueref) = @_;
3855
3856   #only svc_acct can set_usage for now
3857   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3858     my $svc_x = $cust_svc->svc_x;
3859     $svc_x->recharge($valueref)
3860       if $svc_x->can("recharge");
3861   }
3862 }
3863
3864 =item cust_pkg_discount
3865
3866 =cut
3867
3868 sub cust_pkg_discount {
3869   my $self = shift;
3870   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3871 }
3872
3873 =item cust_pkg_discount_active
3874
3875 =cut
3876
3877 sub cust_pkg_discount_active {
3878   my $self = shift;
3879   grep { $_->status eq 'active' } $self->cust_pkg_discount;
3880 }
3881
3882 =item cust_pkg_usage
3883
3884 Returns a list of all voice usage counters attached to this package.
3885
3886 =cut
3887
3888 sub cust_pkg_usage {
3889   my $self = shift;
3890   qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
3891 }
3892
3893 =item apply_usage OPTIONS
3894
3895 Takes the following options:
3896 - cdr: a call detail record (L<FS::cdr>)
3897 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
3898 - minutes: the maximum number of minutes to be charged
3899
3900 Finds available usage minutes for a call of this class, and subtracts
3901 up to that many minutes from the usage pool.  If the usage pool is empty,
3902 and the C<cdr-minutes_priority> global config option is set, minutes may
3903 be taken from other calls as well.  Either way, an allocation record will
3904 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
3905 number of minutes of usage applied to the call.
3906
3907 =cut
3908
3909 sub apply_usage {
3910   my ($self, %opt) = @_;
3911   my $cdr = $opt{cdr};
3912   my $rate_detail = $opt{rate_detail};
3913   my $minutes = $opt{minutes};
3914   my $classnum = $rate_detail->classnum;
3915   my $pkgnum = $self->pkgnum;
3916   my $custnum = $self->custnum;
3917
3918   local $SIG{HUP} = 'IGNORE';
3919   local $SIG{INT} = 'IGNORE'; 
3920   local $SIG{QUIT} = 'IGNORE';
3921   local $SIG{TERM} = 'IGNORE';
3922   local $SIG{TSTP} = 'IGNORE'; 
3923   local $SIG{PIPE} = 'IGNORE'; 
3924
3925   my $oldAutoCommit = $FS::UID::AutoCommit;
3926   local $FS::UID::AutoCommit = 0;
3927   my $dbh = dbh;
3928   my $order = FS::Conf->new->config('cdr-minutes_priority');
3929
3930   my $is_classnum;
3931   if ( $classnum ) {
3932     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
3933   } else {
3934     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
3935   }
3936   my @usage_recs = qsearch({
3937       'table'     => 'cust_pkg_usage',
3938       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
3939                      ' JOIN cust_pkg             USING (pkgnum)'.
3940                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
3941       'select'    => 'cust_pkg_usage.*',
3942       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
3943                      " ( cust_pkg.custnum = $custnum AND ".
3944                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3945                      $is_classnum . ' AND '.
3946                      " cust_pkg_usage.minutes > 0",
3947       'order_by'  => " ORDER BY priority ASC",
3948   });
3949
3950   my $orig_minutes = $minutes;
3951   my $error;
3952   while (!$error and $minutes > 0 and @usage_recs) {
3953     my $cust_pkg_usage = shift @usage_recs;
3954     $cust_pkg_usage->select_for_update;
3955     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
3956         pkgusagenum => $cust_pkg_usage->pkgusagenum,
3957         acctid      => $cdr->acctid,
3958         minutes     => min($cust_pkg_usage->minutes, $minutes),
3959     });
3960     $cust_pkg_usage->set('minutes',
3961       sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes)
3962     );
3963     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
3964     $minutes -= $cdr_cust_pkg_usage->minutes;
3965   }
3966   if ( $order and $minutes > 0 and !$error ) {
3967     # then try to steal minutes from another call
3968     my %search = (
3969         'table'     => 'cdr_cust_pkg_usage',
3970         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
3971                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
3972                        ' JOIN cust_pkg              USING (pkgnum)'.
3973                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
3974                        ' JOIN cdr                   USING (acctid)',
3975         'select'    => 'cdr_cust_pkg_usage.*',
3976         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
3977                        " ( cust_pkg.pkgnum = $pkgnum OR ".
3978                        " ( cust_pkg.custnum = $custnum AND ".
3979                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3980                        " part_pkg_usage_class.classnum = $classnum",
3981         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
3982     );
3983     if ( $order eq 'time' ) {
3984       # find CDRs that are using minutes, but have a later startdate
3985       # than this call
3986       my $startdate = $cdr->startdate;
3987       if ($startdate !~ /^\d+$/) {
3988         die "bad cdr startdate '$startdate'";
3989       }
3990       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
3991       # minimize needless reshuffling
3992       $search{'order_by'} .= ', cdr.startdate DESC';
3993     } else {
3994       # XXX may not work correctly with rate_time schedules.  Could 
3995       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
3996       # think...
3997       $search{'addl_from'} .=
3998         ' JOIN rate_detail'.
3999         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4000       if ( $order eq 'rate_high' ) {
4001         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4002                                 $rate_detail->min_charge;
4003         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4004       } elsif ( $order eq 'rate_low' ) {
4005         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4006                                 $rate_detail->min_charge;
4007         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4008       } else {
4009         #  this should really never happen
4010         die "invalid cdr-minutes_priority value '$order'\n";
4011       }
4012     }
4013     my @cdr_usage_recs = qsearch(\%search);
4014     my %reproc_cdrs;
4015     while (!$error and @cdr_usage_recs and $minutes > 0) {
4016       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4017       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4018       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4019       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4020       $cdr_cust_pkg_usage->select_for_update;
4021       $old_cdr->select_for_update;
4022       $cust_pkg_usage->select_for_update;
4023       # in case someone else stole the usage from this CDR
4024       # while waiting for the lock...
4025       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4026       # steal the usage allocation and flag the old CDR for reprocessing
4027       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4028       # if the allocation is more minutes than we need, adjust it...
4029       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4030       if ( $delta > 0 ) {
4031         $cdr_cust_pkg_usage->set('minutes', $minutes);
4032         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4033         $error = $cust_pkg_usage->replace;
4034       }
4035       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4036       $error ||= $cdr_cust_pkg_usage->replace;
4037       # deduct the stolen minutes
4038       $minutes -= $cdr_cust_pkg_usage->minutes;
4039     }
4040     # after all minute-stealing is done, reset the affected CDRs
4041     foreach (values %reproc_cdrs) {
4042       $error ||= $_->set_status('');
4043       # XXX or should we just call $cdr->rate right here?
4044       # it's not like we can create a loop this way, since the min_charge
4045       # or call time has to go monotonically in one direction.
4046       # we COULD get some very deep recursions going, though...
4047     }
4048   } # if $order and $minutes
4049   if ( $error ) {
4050     $dbh->rollback;
4051     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4052   } else {
4053     $dbh->commit if $oldAutoCommit;
4054     return $orig_minutes - $minutes;
4055   }
4056 }
4057
4058 =item supplemental_pkgs
4059
4060 Returns a list of all packages supplemental to this one.
4061
4062 =cut
4063
4064 sub supplemental_pkgs {
4065   my $self = shift;
4066   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4067 }
4068
4069 =item main_pkg
4070
4071 Returns the package that this one is supplemental to, if any.
4072
4073 =cut
4074
4075 sub main_pkg {
4076   my $self = shift;
4077   if ( $self->main_pkgnum ) {
4078     return FS::cust_pkg->by_key($self->main_pkgnum);
4079   }
4080   return;
4081 }
4082
4083 =back
4084
4085 =head1 CLASS METHODS
4086
4087 =over 4
4088
4089 =item recurring_sql
4090
4091 Returns an SQL expression identifying recurring packages.
4092
4093 =cut
4094
4095 sub recurring_sql { "
4096   '0' != ( select freq from part_pkg
4097              where cust_pkg.pkgpart = part_pkg.pkgpart )
4098 "; }
4099
4100 =item onetime_sql
4101
4102 Returns an SQL expression identifying one-time packages.
4103
4104 =cut
4105
4106 sub onetime_sql { "
4107   '0' = ( select freq from part_pkg
4108             where cust_pkg.pkgpart = part_pkg.pkgpart )
4109 "; }
4110
4111 =item ordered_sql
4112
4113 Returns an SQL expression identifying ordered packages (recurring packages not
4114 yet billed).
4115
4116 =cut
4117
4118 sub ordered_sql {
4119    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4120 }
4121
4122 =item active_sql
4123
4124 Returns an SQL expression identifying active packages.
4125
4126 =cut
4127
4128 sub active_sql {
4129   $_[0]->recurring_sql. "
4130   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4131   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4132   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4133 "; }
4134
4135 =item not_yet_billed_sql
4136
4137 Returns an SQL expression identifying packages which have not yet been billed.
4138
4139 =cut
4140
4141 sub not_yet_billed_sql { "
4142       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4143   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4144   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4145 "; }
4146
4147 =item inactive_sql
4148
4149 Returns an SQL expression identifying inactive packages (one-time packages
4150 that are otherwise unsuspended/uncancelled).
4151
4152 =cut
4153
4154 sub inactive_sql { "
4155   ". $_[0]->onetime_sql(). "
4156   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4157   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4158   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4159 "; }
4160
4161 =item susp_sql
4162 =item suspended_sql
4163
4164 Returns an SQL expression identifying suspended packages.
4165
4166 =cut
4167
4168 sub suspended_sql { susp_sql(@_); }
4169 sub susp_sql {
4170   #$_[0]->recurring_sql(). ' AND '.
4171   "
4172         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4173     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4174   ";
4175 }
4176
4177 =item cancel_sql
4178 =item cancelled_sql
4179
4180 Returns an SQL exprression identifying cancelled packages.
4181
4182 =cut
4183
4184 sub cancelled_sql { cancel_sql(@_); }
4185 sub cancel_sql { 
4186   #$_[0]->recurring_sql(). ' AND '.
4187   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4188 }
4189
4190 =item status_sql
4191
4192 Returns an SQL expression to give the package status as a string.
4193
4194 =cut
4195
4196 sub status_sql {
4197 "CASE
4198   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4199   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4200   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4201   WHEN ".onetime_sql()." THEN 'one-time charge'
4202   ELSE 'active'
4203 END"
4204 }
4205
4206 =item search HASHREF
4207
4208 (Class method)
4209
4210 Returns a qsearch hash expression to search for parameters specified in HASHREF.
4211 Valid parameters are
4212
4213 =over 4
4214
4215 =item agentnum
4216
4217 =item magic
4218
4219 active, inactive, suspended, cancel (or cancelled)
4220
4221 =item status
4222
4223 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
4224
4225 =item custom
4226
4227  boolean selects custom packages
4228
4229 =item classnum
4230
4231 =item pkgpart
4232
4233 pkgpart or arrayref or hashref of pkgparts
4234
4235 =item setup
4236
4237 arrayref of beginning and ending epoch date
4238
4239 =item last_bill
4240
4241 arrayref of beginning and ending epoch date
4242
4243 =item bill
4244
4245 arrayref of beginning and ending epoch date
4246
4247 =item adjourn
4248
4249 arrayref of beginning and ending epoch date
4250
4251 =item susp
4252
4253 arrayref of beginning and ending epoch date
4254
4255 =item expire
4256
4257 arrayref of beginning and ending epoch date
4258
4259 =item cancel
4260
4261 arrayref of beginning and ending epoch date
4262
4263 =item query
4264
4265 pkgnum or APKG_pkgnum
4266
4267 =item cust_fields
4268
4269 a value suited to passing to FS::UI::Web::cust_header
4270
4271 =item CurrentUser
4272
4273 specifies the user for agent virtualization
4274
4275 =item fcc_line
4276
4277 boolean; if true, returns only packages with more than 0 FCC phone lines.
4278
4279 =item state, country
4280
4281 Limit to packages with a service location in the specified state and country.
4282 For FCC 477 reporting, mostly.
4283
4284 =back
4285
4286 =cut
4287
4288 sub search {
4289   my ($class, $params) = @_;
4290   my @where = ();
4291
4292   ##
4293   # parse agent
4294   ##
4295
4296   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4297     push @where,
4298       "cust_main.agentnum = $1";
4299   }
4300
4301   ##
4302   # parse cust_status
4303   ##
4304
4305   if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
4306     push @where, FS::cust_main->cust_status_sql . " = '$1' ";
4307   }
4308
4309   ##
4310   # parse customer sales person
4311   ##
4312
4313   if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
4314     push @where, ($1 > 0) ? "cust_main.salesnum = $1"
4315                           : 'cust_main.salesnum IS NULL';
4316   }
4317
4318
4319   ##
4320   # parse sales person
4321   ##
4322
4323   if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
4324     push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
4325                           : 'cust_pkg.salesnum IS NULL';
4326   }
4327
4328   ##
4329   # parse custnum
4330   ##
4331
4332   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
4333     push @where,
4334       "cust_pkg.custnum = $1";
4335   }
4336
4337   ##
4338   # custbatch
4339   ##
4340
4341   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4342     push @where,
4343       "cust_pkg.pkgbatch = '$1'";
4344   }
4345
4346   ##
4347   # parse status
4348   ##
4349
4350   if (    $params->{'magic'}  eq 'active'
4351        || $params->{'status'} eq 'active' ) {
4352
4353     push @where, FS::cust_pkg->active_sql();
4354
4355   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
4356             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
4357
4358     push @where, FS::cust_pkg->not_yet_billed_sql();
4359
4360   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
4361             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
4362
4363     push @where, FS::cust_pkg->inactive_sql();
4364
4365   } elsif (    $params->{'magic'}  eq 'suspended'
4366             || $params->{'status'} eq 'suspended'  ) {
4367
4368     push @where, FS::cust_pkg->suspended_sql();
4369
4370   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
4371             || $params->{'status'} =~ /^cancell?ed$/ ) {
4372
4373     push @where, FS::cust_pkg->cancelled_sql();
4374
4375   }
4376
4377   ###
4378   # parse package class
4379   ###
4380
4381   if ( exists($params->{'classnum'}) ) {
4382
4383     my @classnum = ();
4384     if ( ref($params->{'classnum'}) ) {
4385
4386       if ( ref($params->{'classnum'}) eq 'HASH' ) {
4387         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
4388       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
4389         @classnum = @{ $params->{'classnum'} };
4390       } else {
4391         die 'unhandled classnum ref '. $params->{'classnum'};
4392       }
4393
4394
4395     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
4396       @classnum = ( $1 );
4397     }
4398
4399     if ( @classnum ) {
4400
4401       my @c_where = ();
4402       my @nums = grep $_, @classnum;
4403       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
4404       my $null = scalar( grep { $_ eq '' } @classnum );
4405       push @c_where, 'part_pkg.classnum IS NULL' if $null;
4406
4407       if ( scalar(@c_where) == 1 ) {
4408         push @where, @c_where;
4409       } elsif ( @c_where ) {
4410         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
4411       }
4412
4413     }
4414     
4415
4416   }
4417
4418   ###
4419   # parse package report options
4420   ###
4421
4422   my @report_option = ();
4423   if ( exists($params->{'report_option'}) ) {
4424     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
4425       @report_option = @{ $params->{'report_option'} };
4426     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
4427       @report_option = split(',', $1);
4428     }
4429
4430   }
4431
4432   if (@report_option) {
4433     # this will result in the empty set for the dangling comma case as it should
4434     push @where, 
4435       map{ "0 < ( SELECT count(*) FROM part_pkg_option
4436                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4437                     AND optionname = 'report_option_$_'
4438                     AND optionvalue = '1' )"
4439          } @report_option;
4440   }
4441
4442   foreach my $any ( grep /^report_option_any/, keys %$params ) {
4443
4444     my @report_option_any = ();
4445     if ( ref($params->{$any}) eq 'ARRAY' ) {
4446       @report_option_any = @{ $params->{$any} };
4447     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
4448       @report_option_any = split(',', $1);
4449     }
4450
4451     if (@report_option_any) {
4452       # this will result in the empty set for the dangling comma case as it should
4453       push @where, ' ( '. join(' OR ',
4454         map{ "0 < ( SELECT count(*) FROM part_pkg_option
4455                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4456                       AND optionname = 'report_option_$_'
4457                       AND optionvalue = '1' )"
4458            } @report_option_any
4459       ). ' ) ';
4460     }
4461
4462   }
4463
4464   ###
4465   # parse custom
4466   ###
4467
4468   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
4469
4470   ###
4471   # parse fcc_line
4472   ###
4473
4474   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
4475                                                         if $params->{fcc_line};
4476
4477   ###
4478   # parse censustract
4479   ###
4480
4481   if ( exists($params->{'censustract'}) ) {
4482     $params->{'censustract'} =~ /^([.\d]*)$/;
4483     my $censustract = "cust_location.censustract = '$1'";
4484     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
4485     push @where,  "( $censustract )";
4486   }
4487
4488   ###
4489   # parse censustract2
4490   ###
4491   if ( exists($params->{'censustract2'})
4492        && $params->{'censustract2'} =~ /^(\d*)$/
4493      )
4494   {
4495     if ($1) {
4496       push @where, "cust_location.censustract LIKE '$1%'";
4497     } else {
4498       push @where,
4499         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
4500     }
4501   }
4502
4503   ###
4504   # parse country/state
4505   ###
4506   for (qw(state country)) { # parsing rules are the same for these
4507   if ( exists($params->{$_}) 
4508     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
4509     {
4510       # XXX post-2.3 only--before that, state/country may be in cust_main
4511       push @where, "cust_location.$_ = '$1'";
4512     }
4513   }
4514
4515   ###
4516   # parse part_pkg
4517   ###
4518
4519   if ( ref($params->{'pkgpart'}) ) {
4520
4521     my @pkgpart = ();
4522     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
4523       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
4524     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
4525       @pkgpart = @{ $params->{'pkgpart'} };
4526     } else {
4527       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
4528     }
4529
4530     @pkgpart = grep /^(\d+)$/, @pkgpart;
4531
4532     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
4533
4534   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4535     push @where, "pkgpart = $1";
4536   } 
4537
4538   ###
4539   # parse dates
4540   ###
4541
4542   my $orderby = '';
4543
4544   #false laziness w/report_cust_pkg.html
4545   my %disable = (
4546     'all'             => {},
4547     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
4548     'active'          => { 'susp'=>1, 'cancel'=>1 },
4549     'suspended'       => { 'cancel' => 1 },
4550     'cancelled'       => {},
4551     ''                => {},
4552   );
4553
4554   if( exists($params->{'active'} ) ) {
4555     # This overrides all the other date-related fields
4556     my($beginning, $ending) = @{$params->{'active'}};
4557     push @where,
4558       "cust_pkg.setup IS NOT NULL",
4559       "cust_pkg.setup <= $ending",
4560       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
4561       "NOT (".FS::cust_pkg->onetime_sql . ")";
4562   }
4563   else {
4564     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
4565
4566       next unless exists($params->{$field});
4567
4568       my($beginning, $ending) = @{$params->{$field}};
4569
4570       next if $beginning == 0 && $ending == 4294967295;
4571
4572       push @where,
4573         "cust_pkg.$field IS NOT NULL",
4574         "cust_pkg.$field >= $beginning",
4575         "cust_pkg.$field <= $ending";
4576
4577       $orderby ||= "ORDER BY cust_pkg.$field";
4578
4579     }
4580   }
4581
4582   $orderby ||= 'ORDER BY bill';
4583
4584   ###
4585   # parse magic, legacy, etc.
4586   ###
4587
4588   if ( $params->{'magic'} &&
4589        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
4590   ) {
4591
4592     $orderby = 'ORDER BY pkgnum';
4593
4594     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4595       push @where, "pkgpart = $1";
4596     }
4597
4598   } elsif ( $params->{'query'} eq 'pkgnum' ) {
4599
4600     $orderby = 'ORDER BY pkgnum';
4601
4602   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
4603
4604     $orderby = 'ORDER BY pkgnum';
4605
4606     push @where, '0 < (
4607       SELECT count(*) FROM pkg_svc
4608        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
4609          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
4610                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
4611                                      AND cust_svc.svcpart = pkg_svc.svcpart
4612                                 )
4613     )';
4614   
4615   }
4616
4617   ##
4618   # setup queries, links, subs, etc. for the search
4619   ##
4620
4621   # here is the agent virtualization
4622   if ($params->{CurrentUser}) {
4623     my $access_user =
4624       qsearchs('access_user', { username => $params->{CurrentUser} });
4625
4626     if ($access_user) {
4627       push @where, $access_user->agentnums_sql('table'=>'cust_main');
4628     } else {
4629       push @where, "1=0";
4630     }
4631   } else {
4632     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
4633   }
4634
4635   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
4636
4637   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
4638                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
4639                   'LEFT JOIN cust_location USING ( locationnum ) '.
4640                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
4641
4642   my $select;
4643   my $count_query;
4644   if ( $params->{'select_zip5'} ) {
4645     my $zip = 'cust_location.zip';
4646
4647     $select = "DISTINCT substr($zip,1,5) as zip";
4648     $orderby = "ORDER BY substr($zip,1,5)";
4649     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
4650   } else {
4651     $select = join(', ',
4652                          'cust_pkg.*',
4653                          ( map "part_pkg.$_", qw( pkg freq ) ),
4654                          'pkg_class.classname',
4655                          'cust_main.custnum AS cust_main_custnum',
4656                          FS::UI::Web::cust_sql_fields(
4657                            $params->{'cust_fields'}
4658                          ),
4659                   );
4660     $count_query = 'SELECT COUNT(*)';
4661   }
4662
4663   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
4664
4665   my $sql_query = {
4666     'table'       => 'cust_pkg',
4667     'hashref'     => {},
4668     'select'      => $select,
4669     'extra_sql'   => $extra_sql,
4670     'order_by'    => $orderby,
4671     'addl_from'   => $addl_from,
4672     'count_query' => $count_query,
4673   };
4674
4675 }
4676
4677 =item fcc_477_count
4678
4679 Returns a list of two package counts.  The first is a count of packages
4680 based on the supplied criteria and the second is the count of residential
4681 packages with those same criteria.  Criteria are specified as in the search
4682 method.
4683
4684 =cut
4685
4686 sub fcc_477_count {
4687   my ($class, $params) = @_;
4688
4689   my $sql_query = $class->search( $params );
4690
4691   my $count_sql = delete($sql_query->{'count_query'});
4692   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4693     or die "couldn't parse count_sql";
4694
4695   my $count_sth = dbh->prepare($count_sql)
4696     or die "Error preparing $count_sql: ". dbh->errstr;
4697   $count_sth->execute
4698     or die "Error executing $count_sql: ". $count_sth->errstr;
4699   my $count_arrayref = $count_sth->fetchrow_arrayref;
4700
4701   return ( @$count_arrayref );
4702
4703 }
4704
4705 =item tax_locationnum_sql
4706
4707 Returns an SQL expression for the tax location for a package, based
4708 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4709
4710 =cut
4711
4712 sub tax_locationnum_sql {
4713   my $conf = FS::Conf->new;
4714   if ( $conf->exists('tax-pkg_address') ) {
4715     'cust_pkg.locationnum';
4716   }
4717   elsif ( $conf->exists('tax-ship_address') ) {
4718     'cust_main.ship_locationnum';
4719   }
4720   else {
4721     'cust_main.bill_locationnum';
4722   }
4723 }
4724
4725 =item location_sql
4726
4727 Returns a list: the first item is an SQL fragment identifying matching 
4728 packages/customers via location (taking into account shipping and package
4729 address taxation, if enabled), and subsequent items are the parameters to
4730 substitute for the placeholders in that fragment.
4731
4732 =cut
4733
4734 sub location_sql {
4735   my($class, %opt) = @_;
4736   my $ornull = $opt{'ornull'};
4737
4738   my $conf = new FS::Conf;
4739
4740   # '?' placeholders in _location_sql_where
4741   my $x = $ornull ? 3 : 2;
4742   my @bill_param = ( 
4743     ('district')x3,
4744     ('city')x3, 
4745     ('county')x$x,
4746     ('state')x$x,
4747     'country'
4748   );
4749
4750   my $main_where;
4751   my @main_param;
4752   if ( $conf->exists('tax-ship_address') ) {
4753
4754     $main_where = "(
4755          (     ( ship_last IS NULL     OR  ship_last  = '' )
4756            AND ". _location_sql_where('cust_main', '', $ornull ). "
4757          )
4758       OR (       ship_last IS NOT NULL AND ship_last != ''
4759            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4760          )
4761     )";
4762     #    AND payby != 'COMP'
4763
4764     @main_param = ( @bill_param, @bill_param );
4765
4766   } else {
4767
4768     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4769     @main_param = @bill_param;
4770
4771   }
4772
4773   my $where;
4774   my @param;
4775   if ( $conf->exists('tax-pkg_address') ) {
4776
4777     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4778
4779     $where = " (
4780                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4781                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4782                )
4783              ";
4784     @param = ( @main_param, @bill_param );
4785   
4786   } else {
4787
4788     $where = $main_where;
4789     @param = @main_param;
4790
4791   }
4792
4793   ( $where, @param );
4794
4795 }
4796
4797 #subroutine, helper for location_sql
4798 sub _location_sql_where {
4799   my $table  = shift;
4800   my $prefix = @_ ? shift : '';
4801   my $ornull = @_ ? shift : '';
4802
4803 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4804
4805   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4806
4807   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4808   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4809   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4810
4811   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4812
4813 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4814   "
4815         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4816     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4817     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
4818     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
4819     AND   $table.${prefix}country  = ?
4820   ";
4821 }
4822
4823 sub _X_show_zero {
4824   my( $self, $what ) = @_;
4825
4826   my $what_show_zero = $what. '_show_zero';
4827   length($self->$what_show_zero())
4828     ? ($self->$what_show_zero() eq 'Y')
4829     : $self->part_pkg->$what_show_zero();
4830 }
4831
4832 =head1 SUBROUTINES
4833
4834 =over 4
4835
4836 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4837
4838 CUSTNUM is a customer (see L<FS::cust_main>)
4839
4840 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4841 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
4842 permitted.
4843
4844 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4845 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
4846 new billing items.  An error is returned if this is not possible (see
4847 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
4848 parameter.
4849
4850 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4851 newly-created cust_pkg objects.
4852
4853 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4854 and inserted.  Multiple FS::pkg_referral records can be created by
4855 setting I<refnum> to an array reference of refnums or a hash reference with
4856 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
4857 record will be created corresponding to cust_main.refnum.
4858
4859 =cut
4860
4861 sub order {
4862   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4863
4864   my $conf = new FS::Conf;
4865
4866   # Transactionize this whole mess
4867   local $SIG{HUP} = 'IGNORE';
4868   local $SIG{INT} = 'IGNORE'; 
4869   local $SIG{QUIT} = 'IGNORE';
4870   local $SIG{TERM} = 'IGNORE';
4871   local $SIG{TSTP} = 'IGNORE'; 
4872   local $SIG{PIPE} = 'IGNORE'; 
4873
4874   my $oldAutoCommit = $FS::UID::AutoCommit;
4875   local $FS::UID::AutoCommit = 0;
4876   my $dbh = dbh;
4877
4878   my $error;
4879 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4880 #  return "Customer not found: $custnum" unless $cust_main;
4881
4882   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4883     if $DEBUG;
4884
4885   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4886                          @$remove_pkgnum;
4887
4888   my $change = scalar(@old_cust_pkg) != 0;
4889
4890   my %hash = (); 
4891   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4892
4893     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4894          " to pkgpart ". $pkgparts->[0]. "\n"
4895       if $DEBUG;
4896
4897     my $err_or_cust_pkg =
4898       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
4899                                 'refnum'  => $refnum,
4900                               );
4901
4902     unless (ref($err_or_cust_pkg)) {
4903       $dbh->rollback if $oldAutoCommit;
4904       return $err_or_cust_pkg;
4905     }
4906
4907     push @$return_cust_pkg, $err_or_cust_pkg;
4908     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4909     return '';
4910
4911   }
4912
4913   # Create the new packages.
4914   foreach my $pkgpart (@$pkgparts) {
4915
4916     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
4917
4918     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
4919                                       pkgpart => $pkgpart,
4920                                       refnum  => $refnum,
4921                                       %hash,
4922                                     };
4923     $error = $cust_pkg->insert( 'change' => $change );
4924     push @$return_cust_pkg, $cust_pkg;
4925
4926     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
4927       my $supp_pkg = FS::cust_pkg->new({
4928           custnum => $custnum,
4929           pkgpart => $link->dst_pkgpart,
4930           refnum  => $refnum,
4931           main_pkgnum => $cust_pkg->pkgnum,
4932           %hash,
4933       });
4934       $error ||= $supp_pkg->insert( 'change' => $change );
4935       push @$return_cust_pkg, $supp_pkg;
4936     }
4937
4938     if ($error) {
4939       $dbh->rollback if $oldAutoCommit;
4940       return $error;
4941     }
4942
4943   }
4944   # $return_cust_pkg now contains refs to all of the newly 
4945   # created packages.
4946
4947   # Transfer services and cancel old packages.
4948   foreach my $old_pkg (@old_cust_pkg) {
4949
4950     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
4951       if $DEBUG;
4952
4953     foreach my $new_pkg (@$return_cust_pkg) {
4954       $error = $old_pkg->transfer($new_pkg);
4955       if ($error and $error == 0) {
4956         # $old_pkg->transfer failed.
4957         $dbh->rollback if $oldAutoCommit;
4958         return $error;
4959       }
4960     }
4961
4962     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
4963       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
4964       foreach my $new_pkg (@$return_cust_pkg) {
4965         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
4966         if ($error and $error == 0) {
4967           # $old_pkg->transfer failed.
4968         $dbh->rollback if $oldAutoCommit;
4969         return $error;
4970         }
4971       }
4972     }
4973
4974     if ($error > 0) {
4975       # Transfers were successful, but we went through all of the 
4976       # new packages and still had services left on the old package.
4977       # We can't cancel the package under the circumstances, so abort.
4978       $dbh->rollback if $oldAutoCommit;
4979       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
4980     }
4981     $error = $old_pkg->cancel( quiet=>1 );
4982     if ($error) {
4983       $dbh->rollback;
4984       return $error;
4985     }
4986   }
4987   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4988   '';
4989 }
4990
4991 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
4992
4993 A bulk change method to change packages for multiple customers.
4994
4995 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4996 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
4997 permitted.
4998
4999 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5000 replace.  The services (see L<FS::cust_svc>) are moved to the
5001 new billing items.  An error is returned if this is not possible (see
5002 L<FS::pkg_svc>).
5003
5004 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5005 newly-created cust_pkg objects.
5006
5007 =cut
5008
5009 sub bulk_change {
5010   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5011
5012   # Transactionize this whole mess
5013   local $SIG{HUP} = 'IGNORE';
5014   local $SIG{INT} = 'IGNORE'; 
5015   local $SIG{QUIT} = 'IGNORE';
5016   local $SIG{TERM} = 'IGNORE';
5017   local $SIG{TSTP} = 'IGNORE'; 
5018   local $SIG{PIPE} = 'IGNORE'; 
5019
5020   my $oldAutoCommit = $FS::UID::AutoCommit;
5021   local $FS::UID::AutoCommit = 0;
5022   my $dbh = dbh;
5023
5024   my @errors;
5025   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5026                          @$remove_pkgnum;
5027
5028   while(scalar(@old_cust_pkg)) {
5029     my @return = ();
5030     my $custnum = $old_cust_pkg[0]->custnum;
5031     my (@remove) = map { $_->pkgnum }
5032                    grep { $_->custnum == $custnum } @old_cust_pkg;
5033     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5034
5035     my $error = order $custnum, $pkgparts, \@remove, \@return;
5036
5037     push @errors, $error
5038       if $error;
5039     push @$return_cust_pkg, @return;
5040   }
5041
5042   if (scalar(@errors)) {
5043     $dbh->rollback if $oldAutoCommit;
5044     return join(' / ', @errors);
5045   }
5046
5047   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5048   '';
5049 }
5050
5051 # Used by FS::Upgrade to migrate to a new database.
5052 sub _upgrade_data {  # class method
5053   my ($class, %opts) = @_;
5054   $class->_upgrade_otaker(%opts);
5055   my @statements = (
5056     # RT#10139, bug resulting in contract_end being set when it shouldn't
5057   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5058     # RT#10830, bad calculation of prorate date near end of year
5059     # the date range for bill is December 2009, and we move it forward
5060     # one year if it's before the previous bill date (which it should 
5061     # never be)
5062   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5063   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5064   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5065     # RT6628, add order_date to cust_pkg
5066     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5067         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5068         history_action = \'insert\') where order_date is null',
5069   );
5070   foreach my $sql (@statements) {
5071     my $sth = dbh->prepare($sql);
5072     $sth->execute or die $sth->errstr;
5073   }
5074 }
5075
5076 =back
5077
5078 =head1 BUGS
5079
5080 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5081
5082 In sub order, the @pkgparts array (passed by reference) is clobbered.
5083
5084 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5085 method to pass dates to the recur_prog expression, it should do so.
5086
5087 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5088 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5089 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5090 configuration values.  Probably need a subroutine which decides what to do
5091 based on whether or not we've fetched the user yet, rather than a hash.  See
5092 FS::UID and the TODO.
5093
5094 Now that things are transactional should the check in the insert method be
5095 moved to check ?
5096
5097 =head1 SEE ALSO
5098
5099 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5100 L<FS::pkg_svc>, schema.html from the base documentation
5101
5102 =cut
5103
5104 1;
5105