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