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