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