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