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