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