fix 'Can't call method "setup" on an undefined value' error when using into rates...
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
5              FS::m2m_Common FS::option_Common );
6 use vars qw($disable_agentcheck $DEBUG $me);
7 use Carp qw(cluck);
8 use Scalar::Util qw( blessed );
9 use List::Util qw(max);
10 use Tie::IxHash;
11 use Time::Local qw( timelocal timelocal_nocheck );
12 use MIME::Entity;
13 use FS::UID qw( getotaker dbh );
14 use FS::Misc qw( send_email );
15 use FS::Record qw( qsearch qsearchs );
16 use FS::CurrentUser;
17 use FS::cust_svc;
18 use FS::part_pkg;
19 use FS::cust_main;
20 use FS::cust_location;
21 use FS::pkg_svc;
22 use FS::cust_bill_pkg;
23 use FS::cust_pkg_detail;
24 use FS::cust_event;
25 use FS::h_cust_svc;
26 use FS::reg_code;
27 use FS::part_svc;
28 use FS::cust_pkg_reason;
29 use FS::reason;
30 use FS::cust_pkg_discount;
31 use FS::discount;
32 use FS::UI::Web;
33 use Data::Dumper;
34
35 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
36 # setup }
37 # because they load configuration by setting FS::UID::callback (see TODO)
38 use FS::svc_acct;
39 use FS::svc_domain;
40 use FS::svc_www;
41 use FS::svc_forward;
42
43 # for sending cancel emails in sub cancel
44 use FS::Conf;
45
46 $DEBUG = 0;
47 $me = '[FS::cust_pkg]';
48
49 $disable_agentcheck = 0;
50
51 sub _cache {
52   my $self = shift;
53   my ( $hashref, $cache ) = @_;
54   #if ( $hashref->{'pkgpart'} ) {
55   if ( $hashref->{'pkg'} ) {
56     # #@{ $self->{'_pkgnum'} } = ();
57     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
58     # $self->{'_pkgpart'} = $subcache;
59     # #push @{ $self->{'_pkgnum'} },
60     #   FS::part_pkg->new_or_cached($hashref, $subcache);
61     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
62   }
63   if ( exists $hashref->{'svcnum'} ) {
64     #@{ $self->{'_pkgnum'} } = ();
65     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
66     $self->{'_svcnum'} = $subcache;
67     #push @{ $self->{'_pkgnum'} },
68     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
69   }
70 }
71
72 =head1 NAME
73
74 FS::cust_pkg - Object methods for cust_pkg objects
75
76 =head1 SYNOPSIS
77
78   use FS::cust_pkg;
79
80   $record = new FS::cust_pkg \%hash;
81   $record = new FS::cust_pkg { 'column' => 'value' };
82
83   $error = $record->insert;
84
85   $error = $new_record->replace($old_record);
86
87   $error = $record->delete;
88
89   $error = $record->check;
90
91   $error = $record->cancel;
92
93   $error = $record->suspend;
94
95   $error = $record->unsuspend;
96
97   $part_pkg = $record->part_pkg;
98
99   @labels = $record->labels;
100
101   $seconds = $record->seconds_since($timestamp);
102
103   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
104   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
105
106 =head1 DESCRIPTION
107
108 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
109 inherits from FS::Record.  The following fields are currently supported:
110
111 =over 4
112
113 =item pkgnum
114
115 Primary key (assigned automatically for new billing items)
116
117 =item custnum
118
119 Customer (see L<FS::cust_main>)
120
121 =item pkgpart
122
123 Billing item definition (see L<FS::part_pkg>)
124
125 =item locationnum
126
127 Optional link to package location (see L<FS::location>)
128
129 =item order_date
130
131 date package was ordered (also remains same on changes)
132
133 =item start_date
134
135 date
136
137 =item setup
138
139 date
140
141 =item bill
142
143 date (next bill date)
144
145 =item last_bill
146
147 last bill date
148
149 =item adjourn
150
151 date
152
153 =item susp
154
155 date
156
157 =item expire
158
159 date
160
161 =item contract_end
162
163 date
164
165 =item cancel
166
167 date
168
169 =item usernum
170
171 order taker (see L<FS::access_user>)
172
173 =item manual_flag
174
175 If this field is set to 1, disables the automatic
176 unsuspension of this package when using the B<unsuspendauto> config option.
177
178 =item quantity
179
180 If not set, defaults to 1
181
182 =item change_date
183
184 Date of change from previous package
185
186 =item change_pkgnum
187
188 Previous pkgnum
189
190 =item change_pkgpart
191
192 Previous pkgpart
193
194 =item change_locationnum
195
196 Previous locationnum
197
198 =item waive_setup
199
200 =back
201
202 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
203 are specified as UNIX timestamps; see L<perlfunc/"time">.  Also see
204 L<Time::Local> and L<Date::Parse> for conversion functions.
205
206 =head1 METHODS
207
208 =over 4
209
210 =item new HASHREF
211
212 Create a new billing item.  To add the item to the database, see L<"insert">.
213
214 =cut
215
216 sub table { 'cust_pkg'; }
217 sub cust_linked { $_[0]->cust_main_custnum; } 
218 sub cust_unlinked_msg {
219   my $self = shift;
220   "WARNING: can't find cust_main.custnum ". $self->custnum.
221   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
222 }
223
224 =item insert [ OPTION => VALUE ... ]
225
226 Adds this billing item to the database ("Orders" the item).  If there is an
227 error, returns the error, otherwise returns false.
228
229 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
230 will be used to look up the package definition and agent restrictions will be
231 ignored.
232
233 If the additional field I<refnum> is defined, an FS::pkg_referral record will
234 be created and inserted.  Multiple FS::pkg_referral records can be created by
235 setting I<refnum> to an array reference of refnums or a hash reference with
236 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
237 record will be created corresponding to cust_main.refnum.
238
239 The following options are available:
240
241 =over 4
242
243 =item change
244
245 If set true, supresses any referral credit to a referring customer.
246
247 =item options
248
249 cust_pkg_option records will be created
250
251 =item ticket_subject
252
253 a ticket will be added to this customer with this subject
254
255 =item ticket_queue
256
257 an optional queue name for ticket additions
258
259 =back
260
261 =cut
262
263 sub insert {
264   my( $self, %options ) = @_;
265
266   my $error = $self->check_pkgpart;
267   return $error if $error;
268
269   my $part_pkg = $self->part_pkg;
270
271   if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
272     my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
273     $mon += 1 unless $mday == 1;
274     until ( $mon < 12 ) { $mon -= 12; $year++; }
275     $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
276   }
277
278   foreach my $action ( qw(expire adjourn contract_end) ) {
279     my $months = $part_pkg->option("${action}_months",1);
280     if($months and !$self->$action) {
281       my $start = $self->start_date || $self->setup || time;
282       $self->$action( $part_pkg->add_freq($start, $months) );
283     }
284   }
285
286   my $free_days = $part_pkg->option('free_days',1);
287   if ( $free_days && $part_pkg->option('delay_setup',1) ) { #&& !$self->start_date
288     my ($mday,$mon,$year) = (localtime(time) )[3,4,5];
289     #my $start_date = ($self->start_date || timelocal(0,0,0,$mday,$mon,$year)) + 86400 * $free_days;
290     my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $free_days;
291     $self->start_date($start_date);
292   }
293
294   $self->order_date(time);
295
296   local $SIG{HUP} = 'IGNORE';
297   local $SIG{INT} = 'IGNORE';
298   local $SIG{QUIT} = 'IGNORE';
299   local $SIG{TERM} = 'IGNORE';
300   local $SIG{TSTP} = 'IGNORE';
301   local $SIG{PIPE} = 'IGNORE';
302
303   my $oldAutoCommit = $FS::UID::AutoCommit;
304   local $FS::UID::AutoCommit = 0;
305   my $dbh = dbh;
306
307   $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
308   if ( $error ) {
309     $dbh->rollback if $oldAutoCommit;
310     return $error;
311   }
312
313   $self->refnum($self->cust_main->refnum) unless $self->refnum;
314   $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
315   $self->process_m2m( 'link_table'   => 'pkg_referral',
316                       'target_table' => 'part_referral',
317                       'params'       => $self->refnum,
318                     );
319
320   if ( $self->discountnum ) {
321     my $error = $self->insert_discount();
322     if ( $error ) {
323       $dbh->rollback if $oldAutoCommit;
324       return $error;
325     }
326   }
327
328   #if ( $self->reg_code ) {
329   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
330   #  $error = $reg_code->delete;
331   #  if ( $error ) {
332   #    $dbh->rollback if $oldAutoCommit;
333   #    return $error;
334   #  }
335   #}
336
337   my $conf = new FS::Conf;
338
339   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
340
341     #eval '
342     #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
343     #  use RT;
344     #';
345     #die $@ if $@;
346     #
347     #RT::LoadConfig();
348     #RT::Init();
349     use FS::TicketSystem;
350     FS::TicketSystem->init();
351
352     my $q = new RT::Queue($RT::SystemUser);
353     $q->Load($options{ticket_queue}) if $options{ticket_queue};
354     my $t = new RT::Ticket($RT::SystemUser);
355     my $mime = new MIME::Entity;
356     $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
357     $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
358                 Subject => $options{ticket_subject},
359                 MIMEObj => $mime,
360               );
361     $t->AddLink( Type   => 'MemberOf',
362                  Target => 'freeside://freeside/cust_main/'. $self->custnum,
363                );
364   }
365
366   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
367     my $queue = new FS::queue {
368       'job'     => 'FS::cust_main::queueable_print',
369     };
370     $error = $queue->insert(
371       'custnum'  => $self->custnum,
372       'template' => 'welcome_letter',
373     );
374
375     if ($error) {
376       warn "can't send welcome letter: $error";
377     }
378
379   }
380
381   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
382   '';
383
384 }
385
386 =item delete
387
388 This method now works but you probably shouldn't use it.
389
390 You don't want to delete packages, because there would then be no record
391 the customer ever purchased the package.  Instead, see the cancel method and
392 hide cancelled packages.
393
394 =cut
395
396 sub delete {
397   my $self = shift;
398
399   local $SIG{HUP} = 'IGNORE';
400   local $SIG{INT} = 'IGNORE';
401   local $SIG{QUIT} = 'IGNORE';
402   local $SIG{TERM} = 'IGNORE';
403   local $SIG{TSTP} = 'IGNORE';
404   local $SIG{PIPE} = 'IGNORE';
405
406   my $oldAutoCommit = $FS::UID::AutoCommit;
407   local $FS::UID::AutoCommit = 0;
408   my $dbh = dbh;
409
410   foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
411     my $error = $cust_pkg_discount->delete;
412     if ( $error ) {
413       $dbh->rollback if $oldAutoCommit;
414       return $error;
415     }
416   }
417   #cust_bill_pkg_discount?
418
419   foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
420     my $error = $cust_pkg_detail->delete;
421     if ( $error ) {
422       $dbh->rollback if $oldAutoCommit;
423       return $error;
424     }
425   }
426
427   foreach my $cust_pkg_reason (
428     qsearchs( {
429                 'table' => 'cust_pkg_reason',
430                 'hashref' => { 'pkgnum' => $self->pkgnum },
431               }
432             )
433   ) {
434     my $error = $cust_pkg_reason->delete;
435     if ( $error ) {
436       $dbh->rollback if $oldAutoCommit;
437       return $error;
438     }
439   }
440
441   #pkg_referral?
442
443   my $error = $self->SUPER::delete(@_);
444   if ( $error ) {
445     $dbh->rollback if $oldAutoCommit;
446     return $error;
447   }
448
449   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
450
451   '';
452
453 }
454
455 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
456
457 Replaces the OLD_RECORD with this one in the database.  If there is an error,
458 returns the error, otherwise returns false.
459
460 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
461
462 Changing pkgpart may have disasterous effects.  See the order subroutine.
463
464 setup and bill are normally updated by calling the bill method of a customer
465 object (see L<FS::cust_main>).
466
467 suspend is normally updated by the suspend and unsuspend methods.
468
469 cancel is normally updated by the cancel method (and also the order subroutine
470 in some cases).
471
472 Available options are:
473
474 =over 4
475
476 =item reason
477
478 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.
479
480 =item reason_otaker
481
482 the access_user (see L<FS::access_user>) providing the reason
483
484 =item options
485
486 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
487
488 =back
489
490 =cut
491
492 sub replace {
493   my $new = shift;
494
495   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
496               ? shift
497               : $new->replace_old;
498
499   my $options = 
500     ( ref($_[0]) eq 'HASH' )
501       ? shift
502       : { @_ };
503
504   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
505   #return "Can't change otaker!" if $old->otaker ne $new->otaker;
506
507   #allow this *sigh*
508   #return "Can't change setup once it exists!"
509   #  if $old->getfield('setup') &&
510   #     $old->getfield('setup') != $new->getfield('setup');
511
512   #some logic for bill, susp, cancel?
513
514   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
515
516   local $SIG{HUP} = 'IGNORE';
517   local $SIG{INT} = 'IGNORE';
518   local $SIG{QUIT} = 'IGNORE';
519   local $SIG{TERM} = 'IGNORE';
520   local $SIG{TSTP} = 'IGNORE';
521   local $SIG{PIPE} = 'IGNORE';
522
523   my $oldAutoCommit = $FS::UID::AutoCommit;
524   local $FS::UID::AutoCommit = 0;
525   my $dbh = dbh;
526
527   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
528     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
529       my $error = $new->insert_reason(
530         'reason'        => $options->{'reason'},
531         'date'          => $new->$method,
532         'action'        => $method,
533         'reason_otaker' => $options->{'reason_otaker'},
534       );
535       if ( $error ) {
536         dbh->rollback if $oldAutoCommit;
537         return "Error inserting cust_pkg_reason: $error";
538       }
539     }
540   }
541
542   #save off and freeze RADIUS attributes for any associated svc_acct records
543   my @svc_acct = ();
544   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
545
546                 #also check for specific exports?
547                 # to avoid spurious modify export events
548     @svc_acct = map  { $_->svc_x }
549                 grep { $_->part_svc->svcdb eq 'svc_acct' }
550                      $old->cust_svc;
551
552     $_->snapshot foreach @svc_acct;
553
554   }
555
556   my $error = $new->SUPER::replace($old,
557                                    $options->{options} ? $options->{options} : ()
558                                   );
559   if ( $error ) {
560     $dbh->rollback if $oldAutoCommit;
561     return $error;
562   }
563
564   #for prepaid packages,
565   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
566   foreach my $old_svc_acct ( @svc_acct ) {
567     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
568     my $s_error =
569       $new_svc_acct->replace( $old_svc_acct,
570                               'depend_jobnum' => $options->{depend_jobnum},
571                             );
572     if ( $s_error ) {
573       $dbh->rollback if $oldAutoCommit;
574       return $s_error;
575     }
576   }
577
578   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
579   '';
580
581 }
582
583 =item check
584
585 Checks all fields to make sure this is a valid billing item.  If there is an
586 error, returns the error, otherwise returns false.  Called by the insert and
587 replace methods.
588
589 =cut
590
591 sub check {
592   my $self = shift;
593
594   $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
595
596   my $error = 
597     $self->ut_numbern('pkgnum')
598     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
599     || $self->ut_numbern('pkgpart')
600     || $self->check_pkgpart
601     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
602     || $self->ut_numbern('start_date')
603     || $self->ut_numbern('setup')
604     || $self->ut_numbern('bill')
605     || $self->ut_numbern('susp')
606     || $self->ut_numbern('cancel')
607     || $self->ut_numbern('adjourn')
608     || $self->ut_numbern('expire')
609     || $self->ut_numbern('dundate')
610     || $self->ut_enum('no_auto', [ '', 'Y' ])
611     || $self->ut_enum('waive_setup', [ '', 'Y' ])
612     || $self->ut_numbern('agent_pkgid')
613     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
614     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
615   ;
616   return $error if $error;
617
618   return "A package with both start date (future start) and setup date (already started) will never bill"
619     if $self->start_date && $self->setup;
620
621   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
622
623   if ( $self->dbdef_table->column('manual_flag') ) {
624     $self->manual_flag('') if $self->manual_flag eq ' ';
625     $self->manual_flag =~ /^([01]?)$/
626       or return "Illegal manual_flag ". $self->manual_flag;
627     $self->manual_flag($1);
628   }
629
630   $self->SUPER::check;
631 }
632
633 =item check_pkgpart
634
635 =cut
636
637 sub check_pkgpart {
638   my $self = shift;
639
640   my $error = $self->ut_numbern('pkgpart');
641   return $error if $error;
642
643   if ( $self->reg_code ) {
644
645     unless ( grep { $self->pkgpart == $_->pkgpart }
646              map  { $_->reg_code_pkg }
647              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
648                                      'agentnum' => $self->cust_main->agentnum })
649            ) {
650       return "Unknown registration code";
651     }
652
653   } elsif ( $self->promo_code ) {
654
655     my $promo_part_pkg =
656       qsearchs('part_pkg', {
657         'pkgpart'    => $self->pkgpart,
658         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
659       } );
660     return 'Unknown promotional code' unless $promo_part_pkg;
661
662   } else { 
663
664     unless ( $disable_agentcheck ) {
665       my $agent =
666         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
667       return "agent ". $agent->agentnum. ':'. $agent->agent.
668              " can't purchase pkgpart ". $self->pkgpart
669         unless $agent->pkgpart_hashref->{ $self->pkgpart }
670             || $agent->agentnum == $self->part_pkg->agentnum;
671     }
672
673     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
674     return $error if $error;
675
676   }
677
678   '';
679
680 }
681
682 =item cancel [ OPTION => VALUE ... ]
683
684 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
685 in this package, then cancels the package itself (sets the cancel field to
686 now).
687
688 Available options are:
689
690 =over 4
691
692 =item quiet - can be set true to supress email cancellation notices.
693
694 =item time -  can be set to cancel the package based on a specific future or historical date.  Using time ensures that the remaining amount is calculated correctly.  Note however that this is an immediate cancel and just changes the date.  You are PROBABLY looking to expire the account instead of using this.
695
696 =item reason - 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.
697
698 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
699
700 =item nobill - can be set true to skip billing if it might otherwise be done.
701
702 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
703 not credit it.  This must be set (by change()) when changing the package 
704 to a different pkgpart or location, and probably shouldn't be in any other 
705 case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
706 be used.
707
708 =back
709
710 If there is an error, returns the error, otherwise returns false.
711
712 =cut
713
714 sub cancel {
715   my( $self, %options ) = @_;
716   my $error;
717
718   my $conf = new FS::Conf;
719
720   warn "cust_pkg::cancel called with options".
721        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
722     if $DEBUG;
723
724   local $SIG{HUP} = 'IGNORE';
725   local $SIG{INT} = 'IGNORE';
726   local $SIG{QUIT} = 'IGNORE'; 
727   local $SIG{TERM} = 'IGNORE';
728   local $SIG{TSTP} = 'IGNORE';
729   local $SIG{PIPE} = 'IGNORE';
730
731   my $oldAutoCommit = $FS::UID::AutoCommit;
732   local $FS::UID::AutoCommit = 0;
733   my $dbh = dbh;
734   
735   my $old = $self->select_for_update;
736
737   if ( $old->get('cancel') || $self->get('cancel') ) {
738     dbh->rollback if $oldAutoCommit;
739     return "";  # no error
740   }
741
742   my $date = $options{date} if $options{date}; # expire/cancel later
743   $date = '' if ($date && $date <= time);      # complain instead?
744
745   #race condition: usage could be ongoing until unprovisioned
746   #resolved by performing a change package instead (which unprovisions) and
747   #later cancelling
748   if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
749       my $copy = $self->new({$self->hash});
750       my $error =
751         $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
752       warn "Error billing during cancel, custnum ".
753         #$self->cust_main->custnum. ": $error"
754         ": $error"
755         if $error;
756   }
757
758   my $cancel_time = $options{'time'} || time;
759
760   if ( $options{'reason'} ) {
761     $error = $self->insert_reason( 'reason' => $options{'reason'},
762                                    'action' => $date ? 'expire' : 'cancel',
763                                    'date'   => $date ? $date : $cancel_time,
764                                    'reason_otaker' => $options{'reason_otaker'},
765                                  );
766     if ( $error ) {
767       dbh->rollback if $oldAutoCommit;
768       return "Error inserting cust_pkg_reason: $error";
769     }
770   }
771
772   my %svc_cancel_opt = ();
773   $svc_cancel_opt{'date'} = $date if $date;
774   foreach my $cust_svc (
775     #schwartz
776     map  { $_->[0] }
777     sort { $a->[1] <=> $b->[1] }
778     map  { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
779     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
780   ) {
781     my $part_svc = $cust_svc->part_svc;
782     next if ( defined($part_svc) and $part_svc->preserve );
783     my $error = $cust_svc->cancel( %svc_cancel_opt );
784
785     if ( $error ) {
786       $dbh->rollback if $oldAutoCommit;
787       return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
788              " cust_svc: $error";
789     }
790   }
791
792   unless ($date) {
793
794     # Add a credit for remaining service
795     my $last_bill = $self->getfield('last_bill') || 0;
796     my $next_bill = $self->getfield('bill') || 0;
797     my $do_credit;
798     if ( exists($options{'unused_credit'}) ) {
799       $do_credit = $options{'unused_credit'};
800     }
801     else {
802       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
803     }
804     if ( $do_credit
805           and $last_bill > 0 # the package has been billed
806           and $next_bill > 0 # the package has a next bill date
807           and $next_bill >= $cancel_time # which is in the future
808     ) {
809       my $remaining_value = $self->calc_remain('time' => $cancel_time);
810       if ( $remaining_value > 0 ) {
811         my $error = $self->cust_main->credit(
812           $remaining_value,
813           'Credit for unused time on '. $self->part_pkg->pkg,
814           'reason_type' => $conf->config('cancel_credit_type'),
815         );
816         if ($error) {
817           $dbh->rollback if $oldAutoCommit;
818           return "Error crediting customer \$$remaining_value for unused time".
819                  " on ". $self->part_pkg->pkg. ": $error";
820         }
821       } #if $remaining_value
822     } #if $do_credit
823
824   } #unless $date
825
826   my %hash = $self->hash;
827   $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
828   my $new = new FS::cust_pkg ( \%hash );
829   $error = $new->replace( $self, options => { $self->options } );
830   if ( $error ) {
831     $dbh->rollback if $oldAutoCommit;
832     return $error;
833   }
834
835   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
836   return '' if $date; #no errors
837
838   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
839   if ( !$options{'quiet'} && 
840         $conf->exists('emailcancel', $self->cust_main->agentnum) && 
841         @invoicing_list ) {
842     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
843     my $error = '';
844     if ( $msgnum ) {
845       my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
846       $error = $msg_template->send( 'cust_main' => $self->cust_main,
847                                     'object'    => $self );
848     }
849     else {
850       $error = send_email(
851         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
852         'to'      => \@invoicing_list,
853         'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
854         'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
855       );
856     }
857     #should this do something on errors?
858   }
859
860   ''; #no errors
861
862 }
863
864 =item cancel_if_expired [ NOW_TIMESTAMP ]
865
866 Cancels this package if its expire date has been reached.
867
868 =cut
869
870 sub cancel_if_expired {
871   my $self = shift;
872   my $time = shift || time;
873   return '' unless $self->expire && $self->expire <= $time;
874   my $error = $self->cancel;
875   if ( $error ) {
876     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
877            $self->custnum. ": $error";
878   }
879   '';
880 }
881
882 =item unexpire
883
884 Cancels any pending expiration (sets the expire field to null).
885
886 If there is an error, returns the error, otherwise returns false.
887
888 =cut
889
890 sub unexpire {
891   my( $self, %options ) = @_;
892   my $error;
893
894   local $SIG{HUP} = 'IGNORE';
895   local $SIG{INT} = 'IGNORE';
896   local $SIG{QUIT} = 'IGNORE';
897   local $SIG{TERM} = 'IGNORE';
898   local $SIG{TSTP} = 'IGNORE';
899   local $SIG{PIPE} = 'IGNORE';
900
901   my $oldAutoCommit = $FS::UID::AutoCommit;
902   local $FS::UID::AutoCommit = 0;
903   my $dbh = dbh;
904
905   my $old = $self->select_for_update;
906
907   my $pkgnum = $old->pkgnum;
908   if ( $old->get('cancel') || $self->get('cancel') ) {
909     dbh->rollback if $oldAutoCommit;
910     return "Can't unexpire cancelled package $pkgnum";
911     # or at least it's pointless
912   }
913
914   unless ( $old->get('expire') && $self->get('expire') ) {
915     dbh->rollback if $oldAutoCommit;
916     return "";  # no error
917   }
918
919   my %hash = $self->hash;
920   $hash{'expire'} = '';
921   my $new = new FS::cust_pkg ( \%hash );
922   $error = $new->replace( $self, options => { $self->options } );
923   if ( $error ) {
924     $dbh->rollback if $oldAutoCommit;
925     return $error;
926   }
927
928   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
929
930   ''; #no errors
931
932 }
933
934 =item suspend [ OPTION => VALUE ... ]
935
936 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
937 package, then suspends the package itself (sets the susp field to now).
938
939 Available options are:
940
941 =over 4
942
943 =item reason - 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.
944
945 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
946
947 =back
948
949 If there is an error, returns the error, otherwise returns false.
950
951 =cut
952
953 sub suspend {
954   my( $self, %options ) = @_;
955   my $error;
956
957   local $SIG{HUP} = 'IGNORE';
958   local $SIG{INT} = 'IGNORE';
959   local $SIG{QUIT} = 'IGNORE'; 
960   local $SIG{TERM} = 'IGNORE';
961   local $SIG{TSTP} = 'IGNORE';
962   local $SIG{PIPE} = 'IGNORE';
963
964   my $oldAutoCommit = $FS::UID::AutoCommit;
965   local $FS::UID::AutoCommit = 0;
966   my $dbh = dbh;
967
968   my $old = $self->select_for_update;
969
970   my $pkgnum = $old->pkgnum;
971   if ( $old->get('cancel') || $self->get('cancel') ) {
972     dbh->rollback if $oldAutoCommit;
973     return "Can't suspend cancelled package $pkgnum";
974   }
975
976   if ( $old->get('susp') || $self->get('susp') ) {
977     dbh->rollback if $oldAutoCommit;
978     return "";  # no error                     # complain on adjourn?
979   }
980
981   my $date = $options{date} if $options{date}; # adjourn/suspend later
982   $date = '' if ($date && $date <= time);      # complain instead?
983
984   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
985     dbh->rollback if $oldAutoCommit;
986     return "Package $pkgnum expires before it would be suspended.";
987   }
988
989   my $suspend_time = $options{'time'} || time;
990
991   if ( $options{'reason'} ) {
992     $error = $self->insert_reason( 'reason' => $options{'reason'},
993                                    'action' => $date ? 'adjourn' : 'suspend',
994                                    'date'   => $date ? $date : $suspend_time,
995                                    'reason_otaker' => $options{'reason_otaker'},
996                                  );
997     if ( $error ) {
998       dbh->rollback if $oldAutoCommit;
999       return "Error inserting cust_pkg_reason: $error";
1000     }
1001   }
1002
1003   my %hash = $self->hash;
1004   if ( $date ) {
1005     $hash{'adjourn'} = $date;
1006   } else {
1007     $hash{'susp'} = $suspend_time;
1008   }
1009   my $new = new FS::cust_pkg ( \%hash );
1010   $error = $new->replace( $self, options => { $self->options } );
1011   if ( $error ) {
1012     $dbh->rollback if $oldAutoCommit;
1013     return $error;
1014   }
1015
1016   unless ( $date ) {
1017
1018     my @labels = ();
1019
1020     foreach my $cust_svc (
1021       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
1022     ) {
1023       my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1024
1025       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1026         $dbh->rollback if $oldAutoCommit;
1027         return "Illegal svcdb value in part_svc!";
1028       };
1029       my $svcdb = $1;
1030       require "FS/$svcdb.pm";
1031
1032       my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1033       if ($svc) {
1034         $error = $svc->suspend;
1035         if ( $error ) {
1036           $dbh->rollback if $oldAutoCommit;
1037           return $error;
1038         }
1039         my( $label, $value ) = $cust_svc->label;
1040         push @labels, "$label: $value";
1041       }
1042     }
1043
1044     my $conf = new FS::Conf;
1045     if ( $conf->config('suspend_email_admin') ) {
1046  
1047       my $error = send_email(
1048         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1049                                    #invoice_from ??? well as good as any
1050         'to'      => $conf->config('suspend_email_admin'),
1051         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
1052         'body'    => [
1053           "This is an automatic message from your Freeside installation\n",
1054           "informing you that the following customer package has been suspended:\n",
1055           "\n",
1056           'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1057           'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1058           ( map { "Service : $_\n" } @labels ),
1059         ],
1060       );
1061
1062       if ( $error ) {
1063         warn "WARNING: can't send suspension admin email (suspending anyway): ".
1064              "$error\n";
1065       }
1066
1067     }
1068
1069   }
1070
1071   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1072
1073   ''; #no errors
1074 }
1075
1076 =item unsuspend [ OPTION => VALUE ... ]
1077
1078 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1079 package, then unsuspends the package itself (clears the susp field and the
1080 adjourn field if it is in the past).
1081
1082 Available options are:
1083
1084 =over 4
1085
1086 =item adjust_next_bill
1087
1088 Can be set true to adjust the next bill date forward by
1089 the amount of time the account was inactive.  This was set true by default
1090 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
1091 explicitly requested.  Price plans for which this makes sense (anniversary-date
1092 based than prorate or subscription) could have an option to enable this
1093 behaviour?
1094
1095 =back
1096
1097 If there is an error, returns the error, otherwise returns false.
1098
1099 =cut
1100
1101 sub unsuspend {
1102   my( $self, %opt ) = @_;
1103   my $error;
1104
1105   local $SIG{HUP} = 'IGNORE';
1106   local $SIG{INT} = 'IGNORE';
1107   local $SIG{QUIT} = 'IGNORE'; 
1108   local $SIG{TERM} = 'IGNORE';
1109   local $SIG{TSTP} = 'IGNORE';
1110   local $SIG{PIPE} = 'IGNORE';
1111
1112   my $oldAutoCommit = $FS::UID::AutoCommit;
1113   local $FS::UID::AutoCommit = 0;
1114   my $dbh = dbh;
1115
1116   my $old = $self->select_for_update;
1117
1118   my $pkgnum = $old->pkgnum;
1119   if ( $old->get('cancel') || $self->get('cancel') ) {
1120     dbh->rollback if $oldAutoCommit;
1121     return "Can't unsuspend cancelled package $pkgnum";
1122   }
1123
1124   unless ( $old->get('susp') && $self->get('susp') ) {
1125     dbh->rollback if $oldAutoCommit;
1126     return "";  # no error                     # complain instead?
1127   }
1128
1129   foreach my $cust_svc (
1130     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1131   ) {
1132     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1133
1134     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1135       $dbh->rollback if $oldAutoCommit;
1136       return "Illegal svcdb value in part_svc!";
1137     };
1138     my $svcdb = $1;
1139     require "FS/$svcdb.pm";
1140
1141     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1142     if ($svc) {
1143       $error = $svc->unsuspend;
1144       if ( $error ) {
1145         $dbh->rollback if $oldAutoCommit;
1146         return $error;
1147       }
1148     }
1149
1150   }
1151
1152   my %hash = $self->hash;
1153   my $inactive = time - $hash{'susp'};
1154
1155   my $conf = new FS::Conf;
1156
1157   if ( $inactive > 0 && 
1158        ( $hash{'bill'} || $hash{'setup'} ) &&
1159        ( $opt{'adjust_next_bill'} ||
1160          $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1161          $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1162      ) {
1163
1164     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1165   
1166   }
1167
1168   $hash{'susp'} = '';
1169   $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
1170   my $new = new FS::cust_pkg ( \%hash );
1171   $error = $new->replace( $self, options => { $self->options } );
1172   if ( $error ) {
1173     $dbh->rollback if $oldAutoCommit;
1174     return $error;
1175   }
1176
1177   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1178
1179   ''; #no errors
1180 }
1181
1182 =item unadjourn
1183
1184 Cancels any pending suspension (sets the adjourn field to null).
1185
1186 If there is an error, returns the error, otherwise returns false.
1187
1188 =cut
1189
1190 sub unadjourn {
1191   my( $self, %options ) = @_;
1192   my $error;
1193
1194   local $SIG{HUP} = 'IGNORE';
1195   local $SIG{INT} = 'IGNORE';
1196   local $SIG{QUIT} = 'IGNORE'; 
1197   local $SIG{TERM} = 'IGNORE';
1198   local $SIG{TSTP} = 'IGNORE';
1199   local $SIG{PIPE} = 'IGNORE';
1200
1201   my $oldAutoCommit = $FS::UID::AutoCommit;
1202   local $FS::UID::AutoCommit = 0;
1203   my $dbh = dbh;
1204
1205   my $old = $self->select_for_update;
1206
1207   my $pkgnum = $old->pkgnum;
1208   if ( $old->get('cancel') || $self->get('cancel') ) {
1209     dbh->rollback if $oldAutoCommit;
1210     return "Can't unadjourn cancelled package $pkgnum";
1211     # or at least it's pointless
1212   }
1213
1214   if ( $old->get('susp') || $self->get('susp') ) {
1215     dbh->rollback if $oldAutoCommit;
1216     return "Can't unadjourn suspended package $pkgnum";
1217     # perhaps this is arbitrary
1218   }
1219
1220   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1221     dbh->rollback if $oldAutoCommit;
1222     return "";  # no error
1223   }
1224
1225   my %hash = $self->hash;
1226   $hash{'adjourn'} = '';
1227   my $new = new FS::cust_pkg ( \%hash );
1228   $error = $new->replace( $self, options => { $self->options } );
1229   if ( $error ) {
1230     $dbh->rollback if $oldAutoCommit;
1231     return $error;
1232   }
1233
1234   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1235
1236   ''; #no errors
1237
1238 }
1239
1240
1241 =item change HASHREF | OPTION => VALUE ... 
1242
1243 Changes this package: cancels it and creates a new one, with a different
1244 pkgpart or locationnum or both.  All services are transferred to the new
1245 package (no change will be made if this is not possible).
1246
1247 Options may be passed as a list of key/value pairs or as a hash reference.
1248 Options are:
1249
1250 =over 4
1251
1252 =item locationnum
1253
1254 New locationnum, to change the location for this package.
1255
1256 =item cust_location
1257
1258 New FS::cust_location object, to create a new location and assign it
1259 to this package.
1260
1261 =item pkgpart
1262
1263 New pkgpart (see L<FS::part_pkg>).
1264
1265 =item refnum
1266
1267 New refnum (see L<FS::part_referral>).
1268
1269 =item keep_dates
1270
1271 Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
1272 susp, adjourn, cancel, expire, and contract_end) to the new package.
1273
1274 =back
1275
1276 At least one of locationnum, cust_location, pkgpart, refnum must be specified 
1277 (otherwise, what's the point?)
1278
1279 Returns either the new FS::cust_pkg object or a scalar error.
1280
1281 For example:
1282
1283   my $err_or_new_cust_pkg = $old_cust_pkg->change
1284
1285 =cut
1286
1287 #some false laziness w/order
1288 sub change {
1289   my $self = shift;
1290   my $opt = ref($_[0]) ? shift : { @_ };
1291
1292 #  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1293 #    
1294
1295   my $conf = new FS::Conf;
1296
1297   # Transactionize this whole mess
1298   local $SIG{HUP} = 'IGNORE';
1299   local $SIG{INT} = 'IGNORE'; 
1300   local $SIG{QUIT} = 'IGNORE';
1301   local $SIG{TERM} = 'IGNORE';
1302   local $SIG{TSTP} = 'IGNORE'; 
1303   local $SIG{PIPE} = 'IGNORE'; 
1304
1305   my $oldAutoCommit = $FS::UID::AutoCommit;
1306   local $FS::UID::AutoCommit = 0;
1307   my $dbh = dbh;
1308
1309   my $error;
1310
1311   my %hash = (); 
1312
1313   my $time = time;
1314
1315   #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1316     
1317   #$hash{$_} = $self->$_() foreach qw( setup );
1318
1319   $hash{'setup'} = $time if $self->setup;
1320
1321   $hash{'change_date'} = $time;
1322   $hash{"change_$_"}  = $self->$_()
1323     foreach qw( pkgnum pkgpart locationnum );
1324
1325   if ( $opt->{'cust_location'} &&
1326        ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1327     $error = $opt->{'cust_location'}->insert;
1328     if ( $error ) {
1329       $dbh->rollback if $oldAutoCommit;
1330       return "inserting cust_location (transaction rolled back): $error";
1331     }
1332     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1333   }
1334
1335   my $unused_credit = 0;
1336   if ( $opt->{'keep_dates'} ) {
1337     foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
1338                           start_date contract_end ) ) {
1339       $hash{$date} = $self->getfield($date);
1340     }
1341   }
1342   # Special case.  If the pkgpart is changing, and the customer is
1343   # going to be credited for remaining time, don't keep setup, bill, 
1344   # or last_bill dates, and DO pass the flag to cancel() to credit 
1345   # the customer.
1346   if ( $opt->{'pkgpart'} 
1347       and $opt->{'pkgpart'} != $self->pkgpart
1348       and $self->part_pkg->option('unused_credit_change', 1) ) {
1349     $unused_credit = 1;
1350     $hash{$_} = '' foreach qw(setup bill last_bill);
1351   }
1352
1353   # Create the new package.
1354   my $cust_pkg = new FS::cust_pkg {
1355     custnum      => $self->custnum,
1356     pkgpart      => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
1357     refnum       => ( $opt->{'refnum'}      || $self->refnum       ),
1358     locationnum  => ( $opt->{'locationnum'} || $self->locationnum  ),
1359     %hash,
1360   };
1361
1362   $error = $cust_pkg->insert( 'change' => 1 );
1363   if ($error) {
1364     $dbh->rollback if $oldAutoCommit;
1365     return $error;
1366   }
1367
1368   # Transfer services and cancel old package.
1369
1370   $error = $self->transfer($cust_pkg);
1371   if ($error and $error == 0) {
1372     # $old_pkg->transfer failed.
1373     $dbh->rollback if $oldAutoCommit;
1374     return $error;
1375   }
1376
1377   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1378     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1379     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1380     if ($error and $error == 0) {
1381       # $old_pkg->transfer failed.
1382       $dbh->rollback if $oldAutoCommit;
1383       return $error;
1384     }
1385   }
1386
1387   if ($error > 0) {
1388     # Transfers were successful, but we still had services left on the old
1389     # package.  We can't change the package under this circumstances, so abort.
1390     $dbh->rollback if $oldAutoCommit;
1391     return "Unable to transfer all services from package ". $self->pkgnum;
1392   }
1393
1394   #reset usage if changing pkgpart
1395   # AND usage rollover is off (otherwise adds twice, now and at package bill)
1396   if ($self->pkgpart != $cust_pkg->pkgpart) {
1397     my $part_pkg = $cust_pkg->part_pkg;
1398     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1399                                                  ? ()
1400                                                  : ( 'null' => 1 )
1401                                    )
1402       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
1403
1404     if ($error) {
1405       $dbh->rollback if $oldAutoCommit;
1406       return "Error setting usage values: $error";
1407     }
1408   }
1409
1410   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
1411   #remaining time.
1412   $error = $self->cancel( quiet=>1, unused_credit => $unused_credit );
1413   if ($error) {
1414     $dbh->rollback if $oldAutoCommit;
1415     return $error;
1416   }
1417
1418   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1419     #$self->cust_main
1420     my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1421     if ( $error ) {
1422       $dbh->rollback if $oldAutoCommit;
1423       return $error;
1424     }
1425   }
1426
1427   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1428
1429   $cust_pkg;
1430
1431 }
1432
1433 use Storable 'thaw';
1434 use MIME::Base64;
1435 sub process_bulk_cust_pkg {
1436   my $job = shift;
1437   my $param = thaw(decode_base64(shift));
1438   warn Dumper($param) if $DEBUG;
1439
1440   my $old_part_pkg = qsearchs('part_pkg', 
1441                               { pkgpart => $param->{'old_pkgpart'} });
1442   my $new_part_pkg = qsearchs('part_pkg',
1443                               { pkgpart => $param->{'new_pkgpart'} });
1444   die "Must select a new package type\n" unless $new_part_pkg;
1445   #my $keep_dates = $param->{'keep_dates'} || 0;
1446   my $keep_dates = 1; # there is no good reason to turn this off
1447
1448   local $SIG{HUP} = 'IGNORE';
1449   local $SIG{INT} = 'IGNORE';
1450   local $SIG{QUIT} = 'IGNORE';
1451   local $SIG{TERM} = 'IGNORE';
1452   local $SIG{TSTP} = 'IGNORE';
1453   local $SIG{PIPE} = 'IGNORE';
1454
1455   my $oldAutoCommit = $FS::UID::AutoCommit;
1456   local $FS::UID::AutoCommit = 0;
1457   my $dbh = dbh;
1458
1459   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
1460
1461   my $i = 0;
1462   foreach my $old_cust_pkg ( @cust_pkgs ) {
1463     $i++;
1464     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
1465     if ( $old_cust_pkg->getfield('cancel') ) {
1466       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
1467         $old_cust_pkg->pkgnum."\n"
1468         if $DEBUG;
1469       next;
1470     }
1471     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
1472       if $DEBUG;
1473     my $error = $old_cust_pkg->change(
1474       'pkgpart'     => $param->{'new_pkgpart'},
1475       'keep_dates'  => $keep_dates
1476     );
1477     if ( !ref($error) ) { # change returns the cust_pkg on success
1478       $dbh->rollback;
1479       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
1480     }
1481   }
1482   $dbh->commit if $oldAutoCommit;
1483   return;
1484 }
1485
1486 =item last_bill
1487
1488 Returns the last bill date, or if there is no last bill date, the setup date.
1489 Useful for billing metered services.
1490
1491 =cut
1492
1493 sub last_bill {
1494   my $self = shift;
1495   return $self->setfield('last_bill', $_[0]) if @_;
1496   return $self->getfield('last_bill') if $self->getfield('last_bill');
1497   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1498                                                   'edate'  => $self->bill,  } );
1499   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1500 }
1501
1502 =item last_cust_pkg_reason ACTION
1503
1504 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1505 Returns false if there is no reason or the package is not currenly ACTION'd
1506 ACTION is one of adjourn, susp, cancel, or expire.
1507
1508 =cut
1509
1510 sub last_cust_pkg_reason {
1511   my ( $self, $action ) = ( shift, shift );
1512   my $date = $self->get($action);
1513   qsearchs( {
1514               'table' => 'cust_pkg_reason',
1515               'hashref' => { 'pkgnum' => $self->pkgnum,
1516                              'action' => substr(uc($action), 0, 1),
1517                              'date'   => $date,
1518                            },
1519               'order_by' => 'ORDER BY num DESC LIMIT 1',
1520            } );
1521 }
1522
1523 =item last_reason ACTION
1524
1525 Returns the most recent ACTION FS::reason associated with the package.
1526 Returns false if there is no reason or the package is not currenly ACTION'd
1527 ACTION is one of adjourn, susp, cancel, or expire.
1528
1529 =cut
1530
1531 sub last_reason {
1532   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1533   $cust_pkg_reason->reason
1534     if $cust_pkg_reason;
1535 }
1536
1537 =item part_pkg
1538
1539 Returns the definition for this billing item, as an FS::part_pkg object (see
1540 L<FS::part_pkg>).
1541
1542 =cut
1543
1544 sub part_pkg {
1545   my $self = shift;
1546   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1547   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1548   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1549 }
1550
1551 =item old_cust_pkg
1552
1553 Returns the cancelled package this package was changed from, if any.
1554
1555 =cut
1556
1557 sub old_cust_pkg {
1558   my $self = shift;
1559   return '' unless $self->change_pkgnum;
1560   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1561 }
1562
1563 =item calc_setup
1564
1565 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1566 item.
1567
1568 =cut
1569
1570 sub calc_setup {
1571   my $self = shift;
1572   $self->part_pkg->calc_setup($self, @_);
1573 }
1574
1575 =item calc_recur
1576
1577 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1578 item.
1579
1580 =cut
1581
1582 sub calc_recur {
1583   my $self = shift;
1584   $self->part_pkg->calc_recur($self, @_);
1585 }
1586
1587 =item base_recur
1588
1589 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
1590 item.
1591
1592 =cut
1593
1594 sub base_recur {
1595   my $self = shift;
1596   $self->part_pkg->base_recur($self, @_);
1597 }
1598
1599 =item calc_remain
1600
1601 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1602 billing item.
1603
1604 =cut
1605
1606 sub calc_remain {
1607   my $self = shift;
1608   $self->part_pkg->calc_remain($self, @_);
1609 }
1610
1611 =item calc_cancel
1612
1613 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1614 billing item.
1615
1616 =cut
1617
1618 sub calc_cancel {
1619   my $self = shift;
1620   $self->part_pkg->calc_cancel($self, @_);
1621 }
1622
1623 =item cust_bill_pkg
1624
1625 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1626
1627 =cut
1628
1629 sub cust_bill_pkg {
1630   my $self = shift;
1631   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1632 }
1633
1634 =item cust_pkg_detail [ DETAILTYPE ]
1635
1636 Returns any customer package details for this package (see
1637 L<FS::cust_pkg_detail>).
1638
1639 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1640
1641 =cut
1642
1643 sub cust_pkg_detail {
1644   my $self = shift;
1645   my %hash = ( 'pkgnum' => $self->pkgnum );
1646   $hash{detailtype} = shift if @_;
1647   qsearch({
1648     'table'    => 'cust_pkg_detail',
1649     'hashref'  => \%hash,
1650     'order_by' => 'ORDER BY weight, pkgdetailnum',
1651   });
1652 }
1653
1654 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1655
1656 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1657
1658 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1659
1660 If there is an error, returns the error, otherwise returns false.
1661
1662 =cut
1663
1664 sub set_cust_pkg_detail {
1665   my( $self, $detailtype, @details ) = @_;
1666
1667   local $SIG{HUP} = 'IGNORE';
1668   local $SIG{INT} = 'IGNORE';
1669   local $SIG{QUIT} = 'IGNORE';
1670   local $SIG{TERM} = 'IGNORE';
1671   local $SIG{TSTP} = 'IGNORE';
1672   local $SIG{PIPE} = 'IGNORE';
1673
1674   my $oldAutoCommit = $FS::UID::AutoCommit;
1675   local $FS::UID::AutoCommit = 0;
1676   my $dbh = dbh;
1677
1678   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1679     my $error = $current->delete;
1680     if ( $error ) {
1681       $dbh->rollback if $oldAutoCommit;
1682       return "error removing old detail: $error";
1683     }
1684   }
1685
1686   foreach my $detail ( @details ) {
1687     my $cust_pkg_detail = new FS::cust_pkg_detail {
1688       'pkgnum'     => $self->pkgnum,
1689       'detailtype' => $detailtype,
1690       'detail'     => $detail,
1691     };
1692     my $error = $cust_pkg_detail->insert;
1693     if ( $error ) {
1694       $dbh->rollback if $oldAutoCommit;
1695       return "error adding new detail: $error";
1696     }
1697
1698   }
1699
1700   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1701   '';
1702
1703 }
1704
1705 =item cust_event
1706
1707 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1708
1709 =cut
1710
1711 #false laziness w/cust_bill.pm
1712 sub cust_event {
1713   my $self = shift;
1714   qsearch({
1715     'table'     => 'cust_event',
1716     'addl_from' => 'JOIN part_event USING ( eventpart )',
1717     'hashref'   => { 'tablenum' => $self->pkgnum },
1718     'extra_sql' => " AND eventtable = 'cust_pkg' ",
1719   });
1720 }
1721
1722 =item num_cust_event
1723
1724 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1725
1726 =cut
1727
1728 #false laziness w/cust_bill.pm
1729 sub num_cust_event {
1730   my $self = shift;
1731   my $sql =
1732     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1733     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1734   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
1735   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1736   $sth->fetchrow_arrayref->[0];
1737 }
1738
1739 =item cust_svc [ SVCPART ]
1740
1741 Returns the services for this package, as FS::cust_svc objects (see
1742 L<FS::cust_svc>).  If a svcpart is specified, return only the matching
1743 services.
1744
1745 =cut
1746
1747 sub cust_svc {
1748   my $self = shift;
1749
1750   return () unless $self->num_cust_svc(@_);
1751
1752   if ( @_ ) {
1753     return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
1754                                   'svcpart' => shift,          } );
1755   }
1756
1757   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1758
1759   #if ( $self->{'_svcnum'} ) {
1760   #  values %{ $self->{'_svcnum'}->cache };
1761   #} else {
1762     $self->_sort_cust_svc(
1763       [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1764     );
1765   #}
1766
1767 }
1768
1769 =item overlimit [ SVCPART ]
1770
1771 Returns the services for this package which have exceeded their
1772 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
1773 is specified, return only the matching services.
1774
1775 =cut
1776
1777 sub overlimit {
1778   my $self = shift;
1779   return () unless $self->num_cust_svc(@_);
1780   grep { $_->overlimit } $self->cust_svc(@_);
1781 }
1782
1783 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
1784
1785 Returns historical services for this package created before END TIMESTAMP and
1786 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1787 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
1788 I<pkg_svc.hidden> flag will be omitted.
1789
1790 =cut
1791
1792 sub h_cust_svc {
1793   my $self = shift;
1794   warn "$me _h_cust_svc called on $self\n"
1795     if $DEBUG;
1796
1797   my ($end, $start, $mode) = @_;
1798   my @cust_svc = $self->_sort_cust_svc(
1799     [ qsearch( 'h_cust_svc',
1800       { 'pkgnum' => $self->pkgnum, },  
1801       FS::h_cust_svc->sql_h_search(@_),  
1802     ) ]
1803   );
1804   if ( defined($mode) && $mode eq 'I' ) {
1805     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
1806     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
1807   } else {
1808     return @cust_svc;
1809   }
1810 }
1811
1812 sub _sort_cust_svc {
1813   my( $self, $arrayref ) = @_;
1814
1815   my $sort =
1816     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
1817
1818   map  { $_->[0] }
1819   sort $sort
1820   map {
1821         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1822                                              'svcpart' => $_->svcpart     } );
1823         [ $_,
1824           $pkg_svc ? $pkg_svc->primary_svc : '',
1825           $pkg_svc ? $pkg_svc->quantity : 0,
1826         ];
1827       }
1828   @$arrayref;
1829
1830 }
1831
1832 =item num_cust_svc [ SVCPART ]
1833
1834 Returns the number of provisioned services for this package.  If a svcpart is
1835 specified, counts only the matching services.
1836
1837 =cut
1838
1839 sub num_cust_svc {
1840   my $self = shift;
1841
1842   return $self->{'_num_cust_svc'}
1843     if !scalar(@_)
1844        && exists($self->{'_num_cust_svc'})
1845        && $self->{'_num_cust_svc'} =~ /\d/;
1846
1847   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1848     if $DEBUG > 2;
1849
1850   my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1851   $sql .= ' AND svcpart = ?' if @_;
1852
1853   my $sth = dbh->prepare($sql)     or die  dbh->errstr;
1854   $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1855   $sth->fetchrow_arrayref->[0];
1856 }
1857
1858 =item available_part_svc 
1859
1860 Returns a list of FS::part_svc objects representing services included in this
1861 package but not yet provisioned.  Each FS::part_svc object also has an extra
1862 field, I<num_avail>, which specifies the number of available services.
1863
1864 =cut
1865
1866 sub available_part_svc {
1867   my $self = shift;
1868   grep { $_->num_avail > 0 }
1869     map {
1870           my $part_svc = $_->part_svc;
1871           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1872             $_->quantity - $self->num_cust_svc($_->svcpart);
1873
1874           # more evil encapsulation breakage
1875           if($part_svc->{'Hash'}{'num_avail'} > 0) {
1876             my @exports = $part_svc->part_export_did;
1877             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
1878           }
1879
1880           $part_svc;
1881         }
1882       $self->part_pkg->pkg_svc;
1883 }
1884
1885 =item part_svc [ OPTION => VALUE ... ]
1886
1887 Returns a list of FS::part_svc objects representing provisioned and available
1888 services included in this package.  Each FS::part_svc object also has the
1889 following extra fields:
1890
1891 =over 4
1892
1893 =item num_cust_svc  (count)
1894
1895 =item num_avail     (quantity - count)
1896
1897 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1898
1899 =back
1900
1901 Accepts one option: summarize_size.  If specified and non-zero, will omit the
1902 extra cust_pkg_svc option for objects where num_cust_svc is this size or
1903 greater.
1904
1905 =cut
1906
1907 #svcnum
1908 #label -> ($cust_svc->label)[1]
1909
1910 sub part_svc {
1911   my $self = shift;
1912   my %opt = @_;
1913
1914   #XXX some sort of sort order besides numeric by svcpart...
1915   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1916     my $pkg_svc = $_;
1917     my $part_svc = $pkg_svc->part_svc;
1918     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1919     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1920     $part_svc->{'Hash'}{'num_avail'}    =
1921       max( 0, $pkg_svc->quantity - $num_cust_svc );
1922     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1923         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
1924       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
1925           && $num_cust_svc >= $opt{summarize_size};
1926     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
1927     $part_svc;
1928   } $self->part_pkg->pkg_svc;
1929
1930   #extras
1931   push @part_svc, map {
1932     my $part_svc = $_;
1933     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1934     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1935     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
1936     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1937       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1938     $part_svc;
1939   } $self->extra_part_svc;
1940
1941   @part_svc;
1942
1943 }
1944
1945 =item extra_part_svc
1946
1947 Returns a list of FS::part_svc objects corresponding to services in this
1948 package which are still provisioned but not (any longer) available in the
1949 package definition.
1950
1951 =cut
1952
1953 sub extra_part_svc {
1954   my $self = shift;
1955
1956   my $pkgnum  = $self->pkgnum;
1957   #my $pkgpart = $self->pkgpart;
1958
1959 #  qsearch( {
1960 #    'table'     => 'part_svc',
1961 #    'hashref'   => {},
1962 #    'extra_sql' =>
1963 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
1964 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
1965 #                       AND pkg_svc.pkgpart = ?
1966 #                       AND quantity > 0 
1967 #                 )
1968 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
1969 #                       LEFT JOIN cust_pkg USING ( pkgnum )
1970 #                     WHERE cust_svc.svcpart = part_svc.svcpart
1971 #                       AND pkgnum = ?
1972 #                 )",
1973 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1974 #  } );
1975
1976 #seems to benchmark slightly faster... (or did?)
1977
1978   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
1979   my $pkgparts = join(',', @pkgparts);
1980
1981   qsearch( {
1982     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
1983     #MySQL doesn't grok DISINCT ON
1984     'select'      => 'DISTINCT part_svc.*',
1985     'table'       => 'part_svc',
1986     'addl_from'   =>
1987       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
1988                                AND pkg_svc.pkgpart IN ($pkgparts)
1989                                AND quantity > 0
1990                              )
1991        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
1992        LEFT JOIN cust_pkg USING ( pkgnum )
1993       ",
1994     'hashref'     => {},
1995     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1996     'extra_param' => [ [$self->pkgnum=>'int'] ],
1997   } );
1998 }
1999
2000 =item status
2001
2002 Returns a short status string for this package, currently:
2003
2004 =over 4
2005
2006 =item not yet billed
2007
2008 =item one-time charge
2009
2010 =item active
2011
2012 =item suspended
2013
2014 =item cancelled
2015
2016 =back
2017
2018 =cut
2019
2020 sub status {
2021   my $self = shift;
2022
2023   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2024
2025   return 'cancelled' if $self->get('cancel');
2026   return 'suspended' if $self->susp;
2027   return 'not yet billed' unless $self->setup;
2028   return 'one-time charge' if $freq =~ /^(0|$)/;
2029   return 'active';
2030 }
2031
2032 =item ucfirst_status
2033
2034 Returns the status with the first character capitalized.
2035
2036 =cut
2037
2038 sub ucfirst_status {
2039   ucfirst(shift->status);
2040 }
2041
2042 =item statuses
2043
2044 Class method that returns the list of possible status strings for packages
2045 (see L<the status method|/status>).  For example:
2046
2047   @statuses = FS::cust_pkg->statuses();
2048
2049 =cut
2050
2051 tie my %statuscolor, 'Tie::IxHash', 
2052   'not yet billed'  => '009999', #teal? cyan?
2053   'one-time charge' => '000000',
2054   'active'          => '00CC00',
2055   'suspended'       => 'FF9900',
2056   'cancelled'       => 'FF0000',
2057 ;
2058
2059 sub statuses {
2060   my $self = shift; #could be class...
2061   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2062   #                                    # mayble split btw one-time vs. recur
2063     keys %statuscolor;
2064 }
2065
2066 =item statuscolor
2067
2068 Returns a hex triplet color string for this package's status.
2069
2070 =cut
2071
2072 sub statuscolor {
2073   my $self = shift;
2074   $statuscolor{$self->status};
2075 }
2076
2077 =item pkg_label
2078
2079 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
2080 "pkg-comment" depending on user preference).
2081
2082 =cut
2083
2084 sub pkg_label {
2085   my $self = shift;
2086   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2087   $label = $self->pkgnum. ": $label"
2088     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2089   $label;
2090 }
2091
2092 =item pkg_label_long
2093
2094 Returns a long label for this package, adding the primary service's label to
2095 pkg_label.
2096
2097 =cut
2098
2099 sub pkg_label_long {
2100   my $self = shift;
2101   my $label = $self->pkg_label;
2102   my $cust_svc = $self->primary_cust_svc;
2103   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
2104   $label;
2105 }
2106
2107 =item primary_cust_svc
2108
2109 Returns a primary service (as FS::cust_svc object) if one can be identified.
2110
2111 =cut
2112
2113 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
2114
2115 sub primary_cust_svc {
2116   my $self = shift;
2117
2118   my @cust_svc = $self->cust_svc;
2119
2120   return '' unless @cust_svc; #no serivces - irrelevant then
2121   
2122   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
2123
2124   # primary service as specified in the package definition
2125   # or exactly one service definition with quantity one
2126   my $svcpart = $self->part_pkg->svcpart;
2127   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
2128   return $cust_svc[0] if scalar(@cust_svc) == 1;
2129
2130   #couldn't identify one thing..
2131   return '';
2132 }
2133
2134 =item labels
2135
2136 Returns a list of lists, calling the label method for all services
2137 (see L<FS::cust_svc>) of this billing item.
2138
2139 =cut
2140
2141 sub labels {
2142   my $self = shift;
2143   map { [ $_->label ] } $self->cust_svc;
2144 }
2145
2146 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2147
2148 Like the labels method, but returns historical information on services that
2149 were active as of END_TIMESTAMP and (optionally) not cancelled before
2150 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
2151 I<pkg_svc.hidden> flag will be omitted.
2152
2153 Returns a list of lists, calling the label method for all (historical) services
2154 (see L<FS::h_cust_svc>) of this billing item.
2155
2156 =cut
2157
2158 sub h_labels {
2159   my $self = shift;
2160   warn "$me _h_labels called on $self\n"
2161     if $DEBUG;
2162   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
2163 }
2164
2165 =item labels_short
2166
2167 Like labels, except returns a simple flat list, and shortens long
2168 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2169 identical services to one line that lists the service label and the number of
2170 individual services rather than individual items.
2171
2172 =cut
2173
2174 sub labels_short {
2175   shift->_labels_short( 'labels', @_ );
2176 }
2177
2178 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
2179
2180 Like h_labels, except returns a simple flat list, and shortens long
2181 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2182 identical services to one line that lists the service label and the number of
2183 individual services rather than individual items.
2184
2185 =cut
2186
2187 sub h_labels_short {
2188   shift->_labels_short( 'h_labels', @_ );
2189 }
2190
2191 sub _labels_short {
2192   my( $self, $method ) = ( shift, shift );
2193
2194   warn "$me _labels_short called on $self with $method method\n"
2195     if $DEBUG;
2196
2197   my $conf = new FS::Conf;
2198   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
2199
2200   warn "$me _labels_short populating \%labels\n"
2201     if $DEBUG;
2202
2203   my %labels;
2204   #tie %labels, 'Tie::IxHash';
2205   push @{ $labels{$_->[0]} }, $_->[1]
2206     foreach $self->$method(@_);
2207
2208   warn "$me _labels_short populating \@labels\n"
2209     if $DEBUG;
2210
2211   my @labels;
2212   foreach my $label ( keys %labels ) {
2213     my %seen = ();
2214     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
2215     my $num = scalar(@values);
2216     warn "$me _labels_short $num items for $label\n"
2217       if $DEBUG;
2218
2219     if ( $num > $max_same_services ) {
2220       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
2221         if $DEBUG;
2222       push @labels, "$label ($num)";
2223     } else {
2224       if ( $conf->exists('cust_bill-consolidate_services') ) {
2225         warn "$me _labels_short   consolidating services\n"
2226           if $DEBUG;
2227         # push @labels, "$label: ". join(', ', @values);
2228         while ( @values ) {
2229           my $detail = "$label: ";
2230           $detail .= shift(@values). ', '
2231             while @values
2232                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
2233           $detail =~ s/, $//;
2234           push @labels, $detail;
2235         }
2236         warn "$me _labels_short   done consolidating services\n"
2237           if $DEBUG;
2238       } else {
2239         warn "$me _labels_short   adding service data\n"
2240           if $DEBUG;
2241         push @labels, map { "$label: $_" } @values;
2242       }
2243     }
2244   }
2245
2246  @labels;
2247
2248 }
2249
2250 =item cust_main
2251
2252 Returns the parent customer object (see L<FS::cust_main>).
2253
2254 =cut
2255
2256 sub cust_main {
2257   my $self = shift;
2258   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
2259 }
2260
2261 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
2262
2263 =item cust_location
2264
2265 Returns the location object, if any (see L<FS::cust_location>).
2266
2267 =item cust_location_or_main
2268
2269 If this package is associated with a location, returns the locaiton (see
2270 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
2271
2272 =item location_label [ OPTION => VALUE ... ]
2273
2274 Returns the label of the location object (see L<FS::cust_location>).
2275
2276 =cut
2277
2278 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2279
2280 =item seconds_since TIMESTAMP
2281
2282 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2283 package have been online since TIMESTAMP, according to the session monitor.
2284
2285 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2286 L<Time::Local> and L<Date::Parse> for conversion functions.
2287
2288 =cut
2289
2290 sub seconds_since {
2291   my($self, $since) = @_;
2292   my $seconds = 0;
2293
2294   foreach my $cust_svc (
2295     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2296   ) {
2297     $seconds += $cust_svc->seconds_since($since);
2298   }
2299
2300   $seconds;
2301
2302 }
2303
2304 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2305
2306 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2307 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2308 (exclusive).
2309
2310 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2311 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2312 functions.
2313
2314
2315 =cut
2316
2317 sub seconds_since_sqlradacct {
2318   my($self, $start, $end) = @_;
2319
2320   my $seconds = 0;
2321
2322   foreach my $cust_svc (
2323     grep {
2324       my $part_svc = $_->part_svc;
2325       $part_svc->svcdb eq 'svc_acct'
2326         && scalar($part_svc->part_export('sqlradius'));
2327     } $self->cust_svc
2328   ) {
2329     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2330   }
2331
2332   $seconds;
2333
2334 }
2335
2336 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2337
2338 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2339 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2340 TIMESTAMP_END
2341 (exclusive).
2342
2343 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2344 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2345 functions.
2346
2347 =cut
2348
2349 sub attribute_since_sqlradacct {
2350   my($self, $start, $end, $attrib) = @_;
2351
2352   my $sum = 0;
2353
2354   foreach my $cust_svc (
2355     grep {
2356       my $part_svc = $_->part_svc;
2357       $part_svc->svcdb eq 'svc_acct'
2358         && scalar($part_svc->part_export('sqlradius'));
2359     } $self->cust_svc
2360   ) {
2361     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2362   }
2363
2364   $sum;
2365
2366 }
2367
2368 =item quantity
2369
2370 =cut
2371
2372 sub quantity {
2373   my( $self, $value ) = @_;
2374   if ( defined($value) ) {
2375     $self->setfield('quantity', $value);
2376   }
2377   $self->getfield('quantity') || 1;
2378 }
2379
2380 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2381
2382 Transfers as many services as possible from this package to another package.
2383
2384 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2385 object.  The destination package must already exist.
2386
2387 Services are moved only if the destination allows services with the correct
2388 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
2389 this option with caution!  No provision is made for export differences
2390 between the old and new service definitions.  Probably only should be used
2391 when your exports for all service definitions of a given svcdb are identical.
2392 (attempt a transfer without it first, to move all possible svcpart-matching
2393 services)
2394
2395 Any services that can't be moved remain in the original package.
2396
2397 Returns an error, if there is one; otherwise, returns the number of services 
2398 that couldn't be moved.
2399
2400 =cut
2401
2402 sub transfer {
2403   my ($self, $dest_pkgnum, %opt) = @_;
2404
2405   my $remaining = 0;
2406   my $dest;
2407   my %target;
2408
2409   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2410     $dest = $dest_pkgnum;
2411     $dest_pkgnum = $dest->pkgnum;
2412   } else {
2413     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2414   }
2415
2416   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2417
2418   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2419     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2420   }
2421
2422   foreach my $cust_svc ($dest->cust_svc) {
2423     $target{$cust_svc->svcpart}--;
2424   }
2425
2426   my %svcpart2svcparts = ();
2427   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2428     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2429     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2430       next if exists $svcpart2svcparts{$svcpart};
2431       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2432       $svcpart2svcparts{$svcpart} = [
2433         map  { $_->[0] }
2434         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
2435         map {
2436               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2437                                                    'svcpart' => $_          } );
2438               [ $_,
2439                 $pkg_svc ? $pkg_svc->primary_svc : '',
2440                 $pkg_svc ? $pkg_svc->quantity : 0,
2441               ];
2442             }
2443
2444         grep { $_ != $svcpart }
2445         map  { $_->svcpart }
2446         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2447       ];
2448       warn "alternates for svcpart $svcpart: ".
2449            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2450         if $DEBUG;
2451     }
2452   }
2453
2454   foreach my $cust_svc ($self->cust_svc) {
2455     if($target{$cust_svc->svcpart} > 0) {
2456       $target{$cust_svc->svcpart}--;
2457       my $new = new FS::cust_svc { $cust_svc->hash };
2458       $new->pkgnum($dest_pkgnum);
2459       my $error = $new->replace($cust_svc);
2460       return $error if $error;
2461     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2462       if ( $DEBUG ) {
2463         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2464         warn "alternates to consider: ".
2465              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2466       }
2467       my @alternate = grep {
2468                              warn "considering alternate svcpart $_: ".
2469                                   "$target{$_} available in new package\n"
2470                                if $DEBUG;
2471                              $target{$_} > 0;
2472                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
2473       if ( @alternate ) {
2474         warn "alternate(s) found\n" if $DEBUG;
2475         my $change_svcpart = $alternate[0];
2476         $target{$change_svcpart}--;
2477         my $new = new FS::cust_svc { $cust_svc->hash };
2478         $new->svcpart($change_svcpart);
2479         $new->pkgnum($dest_pkgnum);
2480         my $error = $new->replace($cust_svc);
2481         return $error if $error;
2482       } else {
2483         $remaining++;
2484       }
2485     } else {
2486       $remaining++
2487     }
2488   }
2489   return $remaining;
2490 }
2491
2492 =item reexport
2493
2494 This method is deprecated.  See the I<depend_jobnum> option to the insert and
2495 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2496
2497 =cut
2498
2499 sub reexport {
2500   my $self = shift;
2501
2502   local $SIG{HUP} = 'IGNORE';
2503   local $SIG{INT} = 'IGNORE';
2504   local $SIG{QUIT} = 'IGNORE';
2505   local $SIG{TERM} = 'IGNORE';
2506   local $SIG{TSTP} = 'IGNORE';
2507   local $SIG{PIPE} = 'IGNORE';
2508
2509   my $oldAutoCommit = $FS::UID::AutoCommit;
2510   local $FS::UID::AutoCommit = 0;
2511   my $dbh = dbh;
2512
2513   foreach my $cust_svc ( $self->cust_svc ) {
2514     #false laziness w/svc_Common::insert
2515     my $svc_x = $cust_svc->svc_x;
2516     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2517       my $error = $part_export->export_insert($svc_x);
2518       if ( $error ) {
2519         $dbh->rollback if $oldAutoCommit;
2520         return $error;
2521       }
2522     }
2523   }
2524
2525   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2526   '';
2527
2528 }
2529
2530 =item insert_reason
2531
2532 Associates this package with a (suspension or cancellation) reason (see
2533 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2534 L<FS::reason>).
2535
2536 Available options are:
2537
2538 =over 4
2539
2540 =item reason
2541
2542 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.
2543
2544 =item reason_otaker
2545
2546 the access_user (see L<FS::access_user>) providing the reason
2547
2548 =item date
2549
2550 a unix timestamp 
2551
2552 =item action
2553
2554 the action (cancel, susp, adjourn, expire) associated with the reason
2555
2556 =back
2557
2558 If there is an error, returns the error, otherwise returns false.
2559
2560 =cut
2561
2562 sub insert_reason {
2563   my ($self, %options) = @_;
2564
2565   my $otaker = $options{reason_otaker} ||
2566                $FS::CurrentUser::CurrentUser->username;
2567
2568   my $reasonnum;
2569   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2570
2571     $reasonnum = $1;
2572
2573   } elsif ( ref($options{'reason'}) ) {
2574   
2575     return 'Enter a new reason (or select an existing one)'
2576       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2577
2578     my $reason = new FS::reason({
2579       'reason_type' => $options{'reason'}->{'typenum'},
2580       'reason'      => $options{'reason'}->{'reason'},
2581     });
2582     my $error = $reason->insert;
2583     return $error if $error;
2584
2585     $reasonnum = $reason->reasonnum;
2586
2587   } else {
2588     return "Unparsable reason: ". $options{'reason'};
2589   }
2590
2591   my $cust_pkg_reason =
2592     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2593                               'reasonnum' => $reasonnum, 
2594                               'otaker'    => $otaker,
2595                               'action'    => substr(uc($options{'action'}),0,1),
2596                               'date'      => $options{'date'}
2597                                                ? $options{'date'}
2598                                                : time,
2599                             });
2600
2601   $cust_pkg_reason->insert;
2602 }
2603
2604 =item insert_discount
2605
2606 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
2607 inserting a new discount on the fly (see L<FS::discount>).
2608
2609 Available options are:
2610
2611 =over 4
2612
2613 =item discountnum
2614
2615 =back
2616
2617 If there is an error, returns the error, otherwise returns false.
2618
2619 =cut
2620
2621 sub insert_discount {
2622   #my ($self, %options) = @_;
2623   my $self = shift;
2624
2625   my $cust_pkg_discount = new FS::cust_pkg_discount {
2626     'pkgnum'      => $self->pkgnum,
2627     'discountnum' => $self->discountnum,
2628     'months_used' => 0,
2629     'end_date'    => '', #XXX
2630     #for the create a new discount case
2631     '_type'       => $self->discountnum__type,
2632     'amount'      => $self->discountnum_amount,
2633     'percent'     => $self->discountnum_percent,
2634     'months'      => $self->discountnum_months,
2635     'setup'      => $self->discountnum_setup,
2636     #'disabled'    => $self->discountnum_disabled,
2637   };
2638
2639   $cust_pkg_discount->insert;
2640 }
2641
2642 =item set_usage USAGE_VALUE_HASHREF 
2643
2644 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2645 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2646 upbytes, downbytes, and totalbytes are appropriate keys.
2647
2648 All svc_accts which are part of this package have their values reset.
2649
2650 =cut
2651
2652 sub set_usage {
2653   my ($self, $valueref, %opt) = @_;
2654
2655   foreach my $cust_svc ($self->cust_svc){
2656     my $svc_x = $cust_svc->svc_x;
2657     $svc_x->set_usage($valueref, %opt)
2658       if $svc_x->can("set_usage");
2659   }
2660 }
2661
2662 =item recharge USAGE_VALUE_HASHREF 
2663
2664 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2665 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2666 upbytes, downbytes, and totalbytes are appropriate keys.
2667
2668 All svc_accts which are part of this package have their values incremented.
2669
2670 =cut
2671
2672 sub recharge {
2673   my ($self, $valueref) = @_;
2674
2675   foreach my $cust_svc ($self->cust_svc){
2676     my $svc_x = $cust_svc->svc_x;
2677     $svc_x->recharge($valueref)
2678       if $svc_x->can("recharge");
2679   }
2680 }
2681
2682 =item cust_pkg_discount
2683
2684 =cut
2685
2686 sub cust_pkg_discount {
2687   my $self = shift;
2688   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
2689 }
2690
2691 =item cust_pkg_discount_active
2692
2693 =cut
2694
2695 sub cust_pkg_discount_active {
2696   my $self = shift;
2697   grep { $_->status eq 'active' } $self->cust_pkg_discount;
2698 }
2699
2700 =back
2701
2702 =head1 CLASS METHODS
2703
2704 =over 4
2705
2706 =item recurring_sql
2707
2708 Returns an SQL expression identifying recurring packages.
2709
2710 =cut
2711
2712 sub recurring_sql { "
2713   '0' != ( select freq from part_pkg
2714              where cust_pkg.pkgpart = part_pkg.pkgpart )
2715 "; }
2716
2717 =item onetime_sql
2718
2719 Returns an SQL expression identifying one-time packages.
2720
2721 =cut
2722
2723 sub onetime_sql { "
2724   '0' = ( select freq from part_pkg
2725             where cust_pkg.pkgpart = part_pkg.pkgpart )
2726 "; }
2727
2728 =item ordered_sql
2729
2730 Returns an SQL expression identifying ordered packages (recurring packages not
2731 yet billed).
2732
2733 =cut
2734
2735 sub ordered_sql {
2736    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
2737 }
2738
2739 =item active_sql
2740
2741 Returns an SQL expression identifying active packages.
2742
2743 =cut
2744
2745 sub active_sql {
2746   $_[0]->recurring_sql. "
2747   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2748   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2749   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2750 "; }
2751
2752 =item not_yet_billed_sql
2753
2754 Returns an SQL expression identifying packages which have not yet been billed.
2755
2756 =cut
2757
2758 sub not_yet_billed_sql { "
2759       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
2760   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2761   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2762 "; }
2763
2764 =item inactive_sql
2765
2766 Returns an SQL expression identifying inactive packages (one-time packages
2767 that are otherwise unsuspended/uncancelled).
2768
2769 =cut
2770
2771 sub inactive_sql { "
2772   ". $_[0]->onetime_sql(). "
2773   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2774   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2775   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2776 "; }
2777
2778 =item susp_sql
2779 =item suspended_sql
2780
2781 Returns an SQL expression identifying suspended packages.
2782
2783 =cut
2784
2785 sub suspended_sql { susp_sql(@_); }
2786 sub susp_sql {
2787   #$_[0]->recurring_sql(). ' AND '.
2788   "
2789         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
2790     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
2791   ";
2792 }
2793
2794 =item cancel_sql
2795 =item cancelled_sql
2796
2797 Returns an SQL exprression identifying cancelled packages.
2798
2799 =cut
2800
2801 sub cancelled_sql { cancel_sql(@_); }
2802 sub cancel_sql { 
2803   #$_[0]->recurring_sql(). ' AND '.
2804   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2805 }
2806
2807 =item status_sql
2808
2809 Returns an SQL expression to give the package status as a string.
2810
2811 =cut
2812
2813 sub status_sql {
2814 "CASE
2815   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
2816   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
2817   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
2818   WHEN ".onetime_sql()." THEN 'one-time charge'
2819   ELSE 'active'
2820 END"
2821 }
2822
2823 =item search HASHREF
2824
2825 (Class method)
2826
2827 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2828 Valid parameters are
2829
2830 =over 4
2831
2832 =item agentnum
2833
2834 =item magic
2835
2836 active, inactive, suspended, cancel (or cancelled)
2837
2838 =item status
2839
2840 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2841
2842 =item custom
2843
2844  boolean selects custom packages
2845
2846 =item classnum
2847
2848 =item pkgpart
2849
2850 pkgpart or arrayref or hashref of pkgparts
2851
2852 =item setup
2853
2854 arrayref of beginning and ending epoch date
2855
2856 =item last_bill
2857
2858 arrayref of beginning and ending epoch date
2859
2860 =item bill
2861
2862 arrayref of beginning and ending epoch date
2863
2864 =item adjourn
2865
2866 arrayref of beginning and ending epoch date
2867
2868 =item susp
2869
2870 arrayref of beginning and ending epoch date
2871
2872 =item expire
2873
2874 arrayref of beginning and ending epoch date
2875
2876 =item cancel
2877
2878 arrayref of beginning and ending epoch date
2879
2880 =item query
2881
2882 pkgnum or APKG_pkgnum
2883
2884 =item cust_fields
2885
2886 a value suited to passing to FS::UI::Web::cust_header
2887
2888 =item CurrentUser
2889
2890 specifies the user for agent virtualization
2891
2892 =item fcc_line
2893
2894  boolean selects packages containing fcc form 477 telco lines
2895
2896 =back
2897
2898 =cut
2899
2900 sub search {
2901   my ($class, $params) = @_;
2902   my @where = ();
2903
2904   ##
2905   # parse agent
2906   ##
2907
2908   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2909     push @where,
2910       "cust_main.agentnum = $1";
2911   }
2912
2913   ##
2914   # parse custnum
2915   ##
2916
2917   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2918     push @where,
2919       "cust_pkg.custnum = $1";
2920   }
2921
2922   ##
2923   # custbatch
2924   ##
2925
2926   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
2927     push @where,
2928       "cust_pkg.pkgbatch = '$1'";
2929   }
2930
2931   ##
2932   # parse status
2933   ##
2934
2935   if (    $params->{'magic'}  eq 'active'
2936        || $params->{'status'} eq 'active' ) {
2937
2938     push @where, FS::cust_pkg->active_sql();
2939
2940   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
2941             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
2942
2943     push @where, FS::cust_pkg->not_yet_billed_sql();
2944
2945   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
2946             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2947
2948     push @where, FS::cust_pkg->inactive_sql();
2949
2950   } elsif (    $params->{'magic'}  eq 'suspended'
2951             || $params->{'status'} eq 'suspended'  ) {
2952
2953     push @where, FS::cust_pkg->suspended_sql();
2954
2955   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
2956             || $params->{'status'} =~ /^cancell?ed$/ ) {
2957
2958     push @where, FS::cust_pkg->cancelled_sql();
2959
2960   }
2961
2962   ###
2963   # parse package class
2964   ###
2965
2966   if ( exists($params->{'classnum'}) ) {
2967
2968     my @classnum = ();
2969     if ( ref($params->{'classnum'}) ) {
2970
2971       if ( ref($params->{'classnum'}) eq 'HASH' ) {
2972         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
2973       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
2974         @classnum = @{ $params->{'classnum'} };
2975       } else {
2976         die 'unhandled classnum ref '. $params->{'classnum'};
2977       }
2978
2979
2980     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
2981       @classnum = ( $1 );
2982     }
2983
2984     if ( @classnum ) {
2985
2986       my @c_where = ();
2987       my @nums = grep $_, @classnum;
2988       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
2989       my $null = scalar( grep { $_ eq '' } @classnum );
2990       push @c_where, 'part_pkg.classnum IS NULL' if $null;
2991
2992       if ( scalar(@c_where) == 1 ) {
2993         push @where, @c_where;
2994       } elsif ( @c_where ) {
2995         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
2996       }
2997       warn $where[-1];
2998
2999     }
3000     
3001
3002   }
3003
3004   ###
3005   # parse package report options
3006   ###
3007
3008   my @report_option = ();
3009   if ( exists($params->{'report_option'}) ) {
3010     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
3011       @report_option = @{ $params->{'report_option'} };
3012     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
3013       @report_option = split(',', $1);
3014     }
3015
3016   }
3017
3018   if (@report_option) {
3019     # this will result in the empty set for the dangling comma case as it should
3020     push @where, 
3021       map{ "0 < ( SELECT count(*) FROM part_pkg_option
3022                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3023                     AND optionname = 'report_option_$_'
3024                     AND optionvalue = '1' )"
3025          } @report_option;
3026   }
3027
3028   foreach my $any ( grep /^report_option_any/, keys %$params ) {
3029
3030     my @report_option_any = ();
3031     if ( ref($params->{$any}) eq 'ARRAY' ) {
3032       @report_option_any = @{ $params->{$any} };
3033     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
3034       @report_option_any = split(',', $1);
3035     }
3036
3037     if (@report_option_any) {
3038       # this will result in the empty set for the dangling comma case as it should
3039       push @where, ' ( '. join(' OR ',
3040         map{ "0 < ( SELECT count(*) FROM part_pkg_option
3041                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3042                       AND optionname = 'report_option_$_'
3043                       AND optionvalue = '1' )"
3044            } @report_option_any
3045       ). ' ) ';
3046     }
3047
3048   }
3049
3050   ###
3051   # parse custom
3052   ###
3053
3054   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
3055
3056   ###
3057   # parse fcc_line
3058   ###
3059
3060   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
3061                                                         if $params->{fcc_line};
3062
3063   ###
3064   # parse censustract
3065   ###
3066
3067   if ( exists($params->{'censustract'}) ) {
3068     $params->{'censustract'} =~ /^([.\d]*)$/;
3069     my $censustract = "cust_main.censustract = '$1'";
3070     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
3071     push @where,  "( $censustract )";
3072   }
3073
3074   ###
3075   # parse censustract2
3076   ###
3077   if ( exists($params->{'censustract2'})
3078        && $params->{'censustract2'} =~ /^(\d*)$/
3079      )
3080   {
3081     if ($1) {
3082       push @where, "cust_main.censustract LIKE '$1%'";
3083     } else {
3084       push @where,
3085         "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
3086     }
3087   }
3088
3089   ###
3090   # parse part_pkg
3091   ###
3092
3093   if ( ref($params->{'pkgpart'}) ) {
3094
3095     my @pkgpart = ();
3096     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
3097       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
3098     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
3099       @pkgpart = @{ $params->{'pkgpart'} };
3100     } else {
3101       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
3102     }
3103
3104     @pkgpart = grep /^(\d+)$/, @pkgpart;
3105
3106     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
3107
3108   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3109     push @where, "pkgpart = $1";
3110   } 
3111
3112   ###
3113   # parse dates
3114   ###
3115
3116   my $orderby = '';
3117
3118   #false laziness w/report_cust_pkg.html
3119   my %disable = (
3120     'all'             => {},
3121     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
3122     'active'          => { 'susp'=>1, 'cancel'=>1 },
3123     'suspended'       => { 'cancel' => 1 },
3124     'cancelled'       => {},
3125     ''                => {},
3126   );
3127
3128   if( exists($params->{'active'} ) ) {
3129     # This overrides all the other date-related fields
3130     my($beginning, $ending) = @{$params->{'active'}};
3131     push @where,
3132       "cust_pkg.setup IS NOT NULL",
3133       "cust_pkg.setup <= $ending",
3134       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
3135       "NOT (".FS::cust_pkg->onetime_sql . ")";
3136   }
3137   else {
3138     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
3139
3140       next unless exists($params->{$field});
3141
3142       my($beginning, $ending) = @{$params->{$field}};
3143
3144       next if $beginning == 0 && $ending == 4294967295;
3145
3146       push @where,
3147         "cust_pkg.$field IS NOT NULL",
3148         "cust_pkg.$field >= $beginning",
3149         "cust_pkg.$field <= $ending";
3150
3151       $orderby ||= "ORDER BY cust_pkg.$field";
3152
3153     }
3154   }
3155
3156   $orderby ||= 'ORDER BY bill';
3157
3158   ###
3159   # parse magic, legacy, etc.
3160   ###
3161
3162   if ( $params->{'magic'} &&
3163        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
3164   ) {
3165
3166     $orderby = 'ORDER BY pkgnum';
3167
3168     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3169       push @where, "pkgpart = $1";
3170     }
3171
3172   } elsif ( $params->{'query'} eq 'pkgnum' ) {
3173
3174     $orderby = 'ORDER BY pkgnum';
3175
3176   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
3177
3178     $orderby = 'ORDER BY pkgnum';
3179
3180     push @where, '0 < (
3181       SELECT count(*) FROM pkg_svc
3182        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
3183          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
3184                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
3185                                      AND cust_svc.svcpart = pkg_svc.svcpart
3186                                 )
3187     )';
3188   
3189   }
3190
3191   ##
3192   # setup queries, links, subs, etc. for the search
3193   ##
3194
3195   # here is the agent virtualization
3196   if ($params->{CurrentUser}) {
3197     my $access_user =
3198       qsearchs('access_user', { username => $params->{CurrentUser} });
3199
3200     if ($access_user) {
3201       push @where, $access_user->agentnums_sql('table'=>'cust_main');
3202     } else {
3203       push @where, "1=0";
3204     }
3205   } else {
3206     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
3207   }
3208
3209   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
3210
3211   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
3212                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
3213                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
3214
3215   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
3216
3217   my $sql_query = {
3218     'table'       => 'cust_pkg',
3219     'hashref'     => {},
3220     'select'      => join(', ',
3221                                 'cust_pkg.*',
3222                                 ( map "part_pkg.$_", qw( pkg freq ) ),
3223                                 'pkg_class.classname',
3224                                 'cust_main.custnum AS cust_main_custnum',
3225                                 FS::UI::Web::cust_sql_fields(
3226                                   $params->{'cust_fields'}
3227                                 ),
3228                      ),
3229     'extra_sql'   => "$extra_sql $orderby",
3230     'addl_from'   => $addl_from,
3231     'count_query' => $count_query,
3232   };
3233
3234 }
3235
3236 =item fcc_477_count
3237
3238 Returns a list of two package counts.  The first is a count of packages
3239 based on the supplied criteria and the second is the count of residential
3240 packages with those same criteria.  Criteria are specified as in the search
3241 method.
3242
3243 =cut
3244
3245 sub fcc_477_count {
3246   my ($class, $params) = @_;
3247
3248   my $sql_query = $class->search( $params );
3249
3250   my $count_sql = delete($sql_query->{'count_query'});
3251   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
3252     or die "couldn't parse count_sql";
3253
3254   my $count_sth = dbh->prepare($count_sql)
3255     or die "Error preparing $count_sql: ". dbh->errstr;
3256   $count_sth->execute
3257     or die "Error executing $count_sql: ". $count_sth->errstr;
3258   my $count_arrayref = $count_sth->fetchrow_arrayref;
3259
3260   return ( @$count_arrayref );
3261
3262 }
3263
3264
3265 =item location_sql
3266
3267 Returns a list: the first item is an SQL fragment identifying matching 
3268 packages/customers via location (taking into account shipping and package
3269 address taxation, if enabled), and subsequent items are the parameters to
3270 substitute for the placeholders in that fragment.
3271
3272 =cut
3273
3274 sub location_sql {
3275   my($class, %opt) = @_;
3276   my $ornull = $opt{'ornull'};
3277
3278   my $conf = new FS::Conf;
3279
3280   # '?' placeholders in _location_sql_where
3281   my $x = $ornull ? 3 : 2;
3282   my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
3283
3284   my $main_where;
3285   my @main_param;
3286   if ( $conf->exists('tax-ship_address') ) {
3287
3288     $main_where = "(
3289          (     ( ship_last IS NULL     OR  ship_last  = '' )
3290            AND ". _location_sql_where('cust_main', '', $ornull ). "
3291          )
3292       OR (       ship_last IS NOT NULL AND ship_last != ''
3293            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
3294          )
3295     )";
3296     #    AND payby != 'COMP'
3297
3298     @main_param = ( @bill_param, @bill_param );
3299
3300   } else {
3301
3302     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
3303     @main_param = @bill_param;
3304
3305   }
3306
3307   my $where;
3308   my @param;
3309   if ( $conf->exists('tax-pkg_address') ) {
3310
3311     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
3312
3313     $where = " (
3314                     ( cust_pkg.locationnum IS     NULL AND $main_where )
3315                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
3316                )
3317              ";
3318     @param = ( @main_param, @bill_param );
3319   
3320   } else {
3321
3322     $where = $main_where;
3323     @param = @main_param;
3324
3325   }
3326
3327   ( $where, @param );
3328
3329 }
3330
3331 #subroutine, helper for location_sql
3332 sub _location_sql_where {
3333   my $table  = shift;
3334   my $prefix = @_ ? shift : '';
3335   my $ornull = @_ ? shift : '';
3336
3337 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
3338
3339   $ornull = $ornull ? ' OR ? IS NULL ' : '';
3340
3341   my $or_empty_city   = " OR ( ? = '' AND $table.${prefix}city   IS NULL ) ";
3342   my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
3343   my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
3344
3345 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
3346   "
3347         ( $table.${prefix}city    = ? OR ? = '' OR CAST(? AS text) IS NULL )
3348     AND ( $table.${prefix}county  = ? $or_empty_county $ornull )
3349     AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
3350     AND   $table.${prefix}country = ?
3351   ";
3352 }
3353
3354 sub _X_show_zero {
3355   my( $self, $what ) = @_;
3356
3357   my $what_show_zero = $what. '_show_zero';
3358   length($self->$what_show_zero())
3359     ? ($self->$what_show_zero() eq 'Y')
3360     : $self->part_pkg->$what_show_zero();
3361 }
3362
3363 =head1 SUBROUTINES
3364
3365 =over 4
3366
3367 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
3368
3369 CUSTNUM is a customer (see L<FS::cust_main>)
3370
3371 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3372 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
3373 permitted.
3374
3375 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
3376 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
3377 new billing items.  An error is returned if this is not possible (see
3378 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
3379 parameter.
3380
3381 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3382 newly-created cust_pkg objects.
3383
3384 REFNUM, if specified, will specify the FS::pkg_referral record to be created
3385 and inserted.  Multiple FS::pkg_referral records can be created by
3386 setting I<refnum> to an array reference of refnums or a hash reference with
3387 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
3388 record will be created corresponding to cust_main.refnum.
3389
3390 =cut
3391
3392 sub order {
3393   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
3394
3395   my $conf = new FS::Conf;
3396
3397   # Transactionize this whole mess
3398   local $SIG{HUP} = 'IGNORE';
3399   local $SIG{INT} = 'IGNORE'; 
3400   local $SIG{QUIT} = 'IGNORE';
3401   local $SIG{TERM} = 'IGNORE';
3402   local $SIG{TSTP} = 'IGNORE'; 
3403   local $SIG{PIPE} = 'IGNORE'; 
3404
3405   my $oldAutoCommit = $FS::UID::AutoCommit;
3406   local $FS::UID::AutoCommit = 0;
3407   my $dbh = dbh;
3408
3409   my $error;
3410 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
3411 #  return "Customer not found: $custnum" unless $cust_main;
3412
3413   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
3414     if $DEBUG;
3415
3416   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3417                          @$remove_pkgnum;
3418
3419   my $change = scalar(@old_cust_pkg) != 0;
3420
3421   my %hash = (); 
3422   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
3423
3424     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
3425          " to pkgpart ". $pkgparts->[0]. "\n"
3426       if $DEBUG;
3427
3428     my $err_or_cust_pkg =
3429       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
3430                                 'refnum'  => $refnum,
3431                               );
3432
3433     unless (ref($err_or_cust_pkg)) {
3434       $dbh->rollback if $oldAutoCommit;
3435       return $err_or_cust_pkg;
3436     }
3437
3438     push @$return_cust_pkg, $err_or_cust_pkg;
3439     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3440     return '';
3441
3442   }
3443
3444   # Create the new packages.
3445   foreach my $pkgpart (@$pkgparts) {
3446
3447     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
3448
3449     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
3450                                       pkgpart => $pkgpart,
3451                                       refnum  => $refnum,
3452                                       %hash,
3453                                     };
3454     $error = $cust_pkg->insert( 'change' => $change );
3455     if ($error) {
3456       $dbh->rollback if $oldAutoCommit;
3457       return $error;
3458     }
3459     push @$return_cust_pkg, $cust_pkg;
3460   }
3461   # $return_cust_pkg now contains refs to all of the newly 
3462   # created packages.
3463
3464   # Transfer services and cancel old packages.
3465   foreach my $old_pkg (@old_cust_pkg) {
3466
3467     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
3468       if $DEBUG;
3469
3470     foreach my $new_pkg (@$return_cust_pkg) {
3471       $error = $old_pkg->transfer($new_pkg);
3472       if ($error and $error == 0) {
3473         # $old_pkg->transfer failed.
3474         $dbh->rollback if $oldAutoCommit;
3475         return $error;
3476       }
3477     }
3478
3479     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3480       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3481       foreach my $new_pkg (@$return_cust_pkg) {
3482         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3483         if ($error and $error == 0) {
3484           # $old_pkg->transfer failed.
3485         $dbh->rollback if $oldAutoCommit;
3486         return $error;
3487         }
3488       }
3489     }
3490
3491     if ($error > 0) {
3492       # Transfers were successful, but we went through all of the 
3493       # new packages and still had services left on the old package.
3494       # We can't cancel the package under the circumstances, so abort.
3495       $dbh->rollback if $oldAutoCommit;
3496       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3497     }
3498     $error = $old_pkg->cancel( quiet=>1 );
3499     if ($error) {
3500       $dbh->rollback;
3501       return $error;
3502     }
3503   }
3504   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3505   '';
3506 }
3507
3508 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3509
3510 A bulk change method to change packages for multiple customers.
3511
3512 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3513 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
3514 permitted.
3515
3516 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3517 replace.  The services (see L<FS::cust_svc>) are moved to the
3518 new billing items.  An error is returned if this is not possible (see
3519 L<FS::pkg_svc>).
3520
3521 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3522 newly-created cust_pkg objects.
3523
3524 =cut
3525
3526 sub bulk_change {
3527   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3528
3529   # Transactionize this whole mess
3530   local $SIG{HUP} = 'IGNORE';
3531   local $SIG{INT} = 'IGNORE'; 
3532   local $SIG{QUIT} = 'IGNORE';
3533   local $SIG{TERM} = 'IGNORE';
3534   local $SIG{TSTP} = 'IGNORE'; 
3535   local $SIG{PIPE} = 'IGNORE'; 
3536
3537   my $oldAutoCommit = $FS::UID::AutoCommit;
3538   local $FS::UID::AutoCommit = 0;
3539   my $dbh = dbh;
3540
3541   my @errors;
3542   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3543                          @$remove_pkgnum;
3544
3545   while(scalar(@old_cust_pkg)) {
3546     my @return = ();
3547     my $custnum = $old_cust_pkg[0]->custnum;
3548     my (@remove) = map { $_->pkgnum }
3549                    grep { $_->custnum == $custnum } @old_cust_pkg;
3550     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3551
3552     my $error = order $custnum, $pkgparts, \@remove, \@return;
3553
3554     push @errors, $error
3555       if $error;
3556     push @$return_cust_pkg, @return;
3557   }
3558
3559   if (scalar(@errors)) {
3560     $dbh->rollback if $oldAutoCommit;
3561     return join(' / ', @errors);
3562   }
3563
3564   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3565   '';
3566 }
3567
3568 # Used by FS::Upgrade to migrate to a new database.
3569 sub _upgrade_data {  # class method
3570   my ($class, %opts) = @_;
3571   $class->_upgrade_otaker(%opts);
3572   my @statements = (
3573     # RT#10139, bug resulting in contract_end being set when it shouldn't
3574   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
3575     # RT#10830, bad calculation of prorate date near end of year
3576     # the date range for bill is December 2009, and we move it forward
3577     # one year if it's before the previous bill date (which it should 
3578     # never be)
3579   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
3580   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
3581   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
3582     # RT6628, add order_date to cust_pkg
3583     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
3584         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
3585         history_action = \'insert\') where order_date is null',
3586   );
3587   foreach my $sql (@statements) {
3588     my $sth = dbh->prepare($sql);
3589     $sth->execute or die $sth->errstr;
3590   }
3591 }
3592
3593 =back
3594
3595 =head1 BUGS
3596
3597 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
3598
3599 In sub order, the @pkgparts array (passed by reference) is clobbered.
3600
3601 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
3602 method to pass dates to the recur_prog expression, it should do so.
3603
3604 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3605 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3606 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3607 configuration values.  Probably need a subroutine which decides what to do
3608 based on whether or not we've fetched the user yet, rather than a hash.  See
3609 FS::UID and the TODO.
3610
3611 Now that things are transactional should the check in the insert method be
3612 moved to check ?
3613
3614 =head1 SEE ALSO
3615
3616 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3617 L<FS::pkg_svc>, schema.html from the base documentation
3618
3619 =cut
3620
3621 1;
3622