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