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