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