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