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