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