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