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