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