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