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