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