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