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