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