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