DSL status pulling, RT#13656
[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
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 svcnum
1900 label -> ($cust_svc->label)[1]
1901
1902 =back
1903
1904 =cut
1905
1906 sub part_svc {
1907   my $self = shift;
1908
1909   #XXX some sort of sort order besides numeric by svcpart...
1910   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1911     my $pkg_svc = $_;
1912     my $part_svc = $pkg_svc->part_svc;
1913     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1914     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1915     $part_svc->{'Hash'}{'num_avail'}    =
1916       max( 0, $pkg_svc->quantity - $num_cust_svc );
1917     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1918       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1919     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
1920     $part_svc;
1921   } $self->part_pkg->pkg_svc;
1922
1923   #extras
1924   push @part_svc, map {
1925     my $part_svc = $_;
1926     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1927     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1928     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
1929     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1930       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1931     $part_svc;
1932   } $self->extra_part_svc;
1933
1934   @part_svc;
1935
1936 }
1937
1938 =item extra_part_svc
1939
1940 Returns a list of FS::part_svc objects corresponding to services in this
1941 package which are still provisioned but not (any longer) available in the
1942 package definition.
1943
1944 =cut
1945
1946 sub extra_part_svc {
1947   my $self = shift;
1948
1949   my $pkgnum  = $self->pkgnum;
1950   #my $pkgpart = $self->pkgpart;
1951
1952 #  qsearch( {
1953 #    'table'     => 'part_svc',
1954 #    'hashref'   => {},
1955 #    'extra_sql' =>
1956 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
1957 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
1958 #                       AND pkg_svc.pkgpart = ?
1959 #                       AND quantity > 0 
1960 #                 )
1961 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
1962 #                       LEFT JOIN cust_pkg USING ( pkgnum )
1963 #                     WHERE cust_svc.svcpart = part_svc.svcpart
1964 #                       AND pkgnum = ?
1965 #                 )",
1966 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1967 #  } );
1968
1969 #seems to benchmark slightly faster... (or did?)
1970
1971   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
1972   my $pkgparts = join(',', @pkgparts);
1973
1974   qsearch( {
1975     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
1976     #MySQL doesn't grok DISINCT ON
1977     'select'      => 'DISTINCT part_svc.*',
1978     'table'       => 'part_svc',
1979     'addl_from'   =>
1980       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
1981                                AND pkg_svc.pkgpart IN ($pkgparts)
1982                                AND quantity > 0
1983                              )
1984        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
1985        LEFT JOIN cust_pkg USING ( pkgnum )
1986       ",
1987     'hashref'     => {},
1988     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1989     'extra_param' => [ [$self->pkgnum=>'int'] ],
1990   } );
1991 }
1992
1993 =item status
1994
1995 Returns a short status string for this package, currently:
1996
1997 =over 4
1998
1999 =item not yet billed
2000
2001 =item one-time charge
2002
2003 =item active
2004
2005 =item suspended
2006
2007 =item cancelled
2008
2009 =back
2010
2011 =cut
2012
2013 sub status {
2014   my $self = shift;
2015
2016   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2017
2018   return 'cancelled' if $self->get('cancel');
2019   return 'suspended' if $self->susp;
2020   return 'not yet billed' unless $self->setup;
2021   return 'one-time charge' if $freq =~ /^(0|$)/;
2022   return 'active';
2023 }
2024
2025 =item ucfirst_status
2026
2027 Returns the status with the first character capitalized.
2028
2029 =cut
2030
2031 sub ucfirst_status {
2032   ucfirst(shift->status);
2033 }
2034
2035 =item statuses
2036
2037 Class method that returns the list of possible status strings for packages
2038 (see L<the status method|/status>).  For example:
2039
2040   @statuses = FS::cust_pkg->statuses();
2041
2042 =cut
2043
2044 tie my %statuscolor, 'Tie::IxHash', 
2045   'not yet billed'  => '009999', #teal? cyan?
2046   'one-time charge' => '000000',
2047   'active'          => '00CC00',
2048   'suspended'       => 'FF9900',
2049   'cancelled'       => 'FF0000',
2050 ;
2051
2052 sub statuses {
2053   my $self = shift; #could be class...
2054   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2055   #                                    # mayble split btw one-time vs. recur
2056     keys %statuscolor;
2057 }
2058
2059 =item statuscolor
2060
2061 Returns a hex triplet color string for this package's status.
2062
2063 =cut
2064
2065 sub statuscolor {
2066   my $self = shift;
2067   $statuscolor{$self->status};
2068 }
2069
2070 =item pkg_label
2071
2072 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
2073 "pkg-comment" depending on user preference).
2074
2075 =cut
2076
2077 sub pkg_label {
2078   my $self = shift;
2079   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2080   $label = $self->pkgnum. ": $label"
2081     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2082   $label;
2083 }
2084
2085 =item pkg_label_long
2086
2087 Returns a long label for this package, adding the primary service's label to
2088 pkg_label.
2089
2090 =cut
2091
2092 sub pkg_label_long {
2093   my $self = shift;
2094   my $label = $self->pkg_label;
2095   my $cust_svc = $self->primary_cust_svc;
2096   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
2097   $label;
2098 }
2099
2100 =item primary_cust_svc
2101
2102 Returns a primary service (as FS::cust_svc object) if one can be identified.
2103
2104 =cut
2105
2106 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
2107
2108 sub primary_cust_svc {
2109   my $self = shift;
2110
2111   my @cust_svc = $self->cust_svc;
2112
2113   return '' unless @cust_svc; #no serivces - irrelevant then
2114   
2115   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
2116
2117   # primary service as specified in the package definition
2118   # or exactly one service definition with quantity one
2119   my $svcpart = $self->part_pkg->svcpart;
2120   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
2121   return $cust_svc[0] if scalar(@cust_svc) == 1;
2122
2123   #couldn't identify one thing..
2124   return '';
2125 }
2126
2127 =item labels
2128
2129 Returns a list of lists, calling the label method for all services
2130 (see L<FS::cust_svc>) of this billing item.
2131
2132 =cut
2133
2134 sub labels {
2135   my $self = shift;
2136   map { [ $_->label ] } $self->cust_svc;
2137 }
2138
2139 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2140
2141 Like the labels method, but returns historical information on services that
2142 were active as of END_TIMESTAMP and (optionally) not cancelled before
2143 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
2144 I<pkg_svc.hidden> flag will be omitted.
2145
2146 Returns a list of lists, calling the label method for all (historical) services
2147 (see L<FS::h_cust_svc>) of this billing item.
2148
2149 =cut
2150
2151 sub h_labels {
2152   my $self = shift;
2153   warn "$me _h_labels called on $self\n"
2154     if $DEBUG;
2155   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
2156 }
2157
2158 =item labels_short
2159
2160 Like labels, except returns a simple flat list, and shortens long
2161 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2162 identical services to one line that lists the service label and the number of
2163 individual services rather than individual items.
2164
2165 =cut
2166
2167 sub labels_short {
2168   shift->_labels_short( 'labels', @_ );
2169 }
2170
2171 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
2172
2173 Like h_labels, except returns a simple flat list, and shortens long
2174 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2175 identical services to one line that lists the service label and the number of
2176 individual services rather than individual items.
2177
2178 =cut
2179
2180 sub h_labels_short {
2181   shift->_labels_short( 'h_labels', @_ );
2182 }
2183
2184 sub _labels_short {
2185   my( $self, $method ) = ( shift, shift );
2186
2187   warn "$me _labels_short called on $self with $method method\n"
2188     if $DEBUG;
2189
2190   my $conf = new FS::Conf;
2191   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
2192
2193   warn "$me _labels_short populating \%labels\n"
2194     if $DEBUG;
2195
2196   my %labels;
2197   #tie %labels, 'Tie::IxHash';
2198   push @{ $labels{$_->[0]} }, $_->[1]
2199     foreach $self->$method(@_);
2200
2201   warn "$me _labels_short populating \@labels\n"
2202     if $DEBUG;
2203
2204   my @labels;
2205   foreach my $label ( keys %labels ) {
2206     my %seen = ();
2207     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
2208     my $num = scalar(@values);
2209     warn "$me _labels_short $num items for $label\n"
2210       if $DEBUG;
2211
2212     if ( $num > $max_same_services ) {
2213       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
2214         if $DEBUG;
2215       push @labels, "$label ($num)";
2216     } else {
2217       if ( $conf->exists('cust_bill-consolidate_services') ) {
2218         warn "$me _labels_short   consolidating services\n"
2219           if $DEBUG;
2220         # push @labels, "$label: ". join(', ', @values);
2221         while ( @values ) {
2222           my $detail = "$label: ";
2223           $detail .= shift(@values). ', '
2224             while @values
2225                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
2226           $detail =~ s/, $//;
2227           push @labels, $detail;
2228         }
2229         warn "$me _labels_short   done consolidating services\n"
2230           if $DEBUG;
2231       } else {
2232         warn "$me _labels_short   adding service data\n"
2233           if $DEBUG;
2234         push @labels, map { "$label: $_" } @values;
2235       }
2236     }
2237   }
2238
2239  @labels;
2240
2241 }
2242
2243 =item cust_main
2244
2245 Returns the parent customer object (see L<FS::cust_main>).
2246
2247 =cut
2248
2249 sub cust_main {
2250   my $self = shift;
2251   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
2252 }
2253
2254 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
2255
2256 =item cust_location
2257
2258 Returns the location object, if any (see L<FS::cust_location>).
2259
2260 =item cust_location_or_main
2261
2262 If this package is associated with a location, returns the locaiton (see
2263 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
2264
2265 =item location_label [ OPTION => VALUE ... ]
2266
2267 Returns the label of the location object (see L<FS::cust_location>).
2268
2269 =cut
2270
2271 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2272
2273 =item seconds_since TIMESTAMP
2274
2275 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2276 package have been online since TIMESTAMP, according to the session monitor.
2277
2278 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2279 L<Time::Local> and L<Date::Parse> for conversion functions.
2280
2281 =cut
2282
2283 sub seconds_since {
2284   my($self, $since) = @_;
2285   my $seconds = 0;
2286
2287   foreach my $cust_svc (
2288     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2289   ) {
2290     $seconds += $cust_svc->seconds_since($since);
2291   }
2292
2293   $seconds;
2294
2295 }
2296
2297 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2298
2299 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2300 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2301 (exclusive).
2302
2303 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2304 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2305 functions.
2306
2307
2308 =cut
2309
2310 sub seconds_since_sqlradacct {
2311   my($self, $start, $end) = @_;
2312
2313   my $seconds = 0;
2314
2315   foreach my $cust_svc (
2316     grep {
2317       my $part_svc = $_->part_svc;
2318       $part_svc->svcdb eq 'svc_acct'
2319         && scalar($part_svc->part_export('sqlradius'));
2320     } $self->cust_svc
2321   ) {
2322     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2323   }
2324
2325   $seconds;
2326
2327 }
2328
2329 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2330
2331 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2332 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2333 TIMESTAMP_END
2334 (exclusive).
2335
2336 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2337 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2338 functions.
2339
2340 =cut
2341
2342 sub attribute_since_sqlradacct {
2343   my($self, $start, $end, $attrib) = @_;
2344
2345   my $sum = 0;
2346
2347   foreach my $cust_svc (
2348     grep {
2349       my $part_svc = $_->part_svc;
2350       $part_svc->svcdb eq 'svc_acct'
2351         && scalar($part_svc->part_export('sqlradius'));
2352     } $self->cust_svc
2353   ) {
2354     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2355   }
2356
2357   $sum;
2358
2359 }
2360
2361 =item quantity
2362
2363 =cut
2364
2365 sub quantity {
2366   my( $self, $value ) = @_;
2367   if ( defined($value) ) {
2368     $self->setfield('quantity', $value);
2369   }
2370   $self->getfield('quantity') || 1;
2371 }
2372
2373 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2374
2375 Transfers as many services as possible from this package to another package.
2376
2377 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2378 object.  The destination package must already exist.
2379
2380 Services are moved only if the destination allows services with the correct
2381 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
2382 this option with caution!  No provision is made for export differences
2383 between the old and new service definitions.  Probably only should be used
2384 when your exports for all service definitions of a given svcdb are identical.
2385 (attempt a transfer without it first, to move all possible svcpart-matching
2386 services)
2387
2388 Any services that can't be moved remain in the original package.
2389
2390 Returns an error, if there is one; otherwise, returns the number of services 
2391 that couldn't be moved.
2392
2393 =cut
2394
2395 sub transfer {
2396   my ($self, $dest_pkgnum, %opt) = @_;
2397
2398   my $remaining = 0;
2399   my $dest;
2400   my %target;
2401
2402   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2403     $dest = $dest_pkgnum;
2404     $dest_pkgnum = $dest->pkgnum;
2405   } else {
2406     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2407   }
2408
2409   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2410
2411   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2412     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2413   }
2414
2415   foreach my $cust_svc ($dest->cust_svc) {
2416     $target{$cust_svc->svcpart}--;
2417   }
2418
2419   my %svcpart2svcparts = ();
2420   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2421     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2422     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2423       next if exists $svcpart2svcparts{$svcpart};
2424       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2425       $svcpart2svcparts{$svcpart} = [
2426         map  { $_->[0] }
2427         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
2428         map {
2429               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2430                                                    'svcpart' => $_          } );
2431               [ $_,
2432                 $pkg_svc ? $pkg_svc->primary_svc : '',
2433                 $pkg_svc ? $pkg_svc->quantity : 0,
2434               ];
2435             }
2436
2437         grep { $_ != $svcpart }
2438         map  { $_->svcpart }
2439         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2440       ];
2441       warn "alternates for svcpart $svcpart: ".
2442            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2443         if $DEBUG;
2444     }
2445   }
2446
2447   foreach my $cust_svc ($self->cust_svc) {
2448     if($target{$cust_svc->svcpart} > 0) {
2449       $target{$cust_svc->svcpart}--;
2450       my $new = new FS::cust_svc { $cust_svc->hash };
2451       $new->pkgnum($dest_pkgnum);
2452       my $error = $new->replace($cust_svc);
2453       return $error if $error;
2454     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2455       if ( $DEBUG ) {
2456         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2457         warn "alternates to consider: ".
2458              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2459       }
2460       my @alternate = grep {
2461                              warn "considering alternate svcpart $_: ".
2462                                   "$target{$_} available in new package\n"
2463                                if $DEBUG;
2464                              $target{$_} > 0;
2465                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
2466       if ( @alternate ) {
2467         warn "alternate(s) found\n" if $DEBUG;
2468         my $change_svcpart = $alternate[0];
2469         $target{$change_svcpart}--;
2470         my $new = new FS::cust_svc { $cust_svc->hash };
2471         $new->svcpart($change_svcpart);
2472         $new->pkgnum($dest_pkgnum);
2473         my $error = $new->replace($cust_svc);
2474         return $error if $error;
2475       } else {
2476         $remaining++;
2477       }
2478     } else {
2479       $remaining++
2480     }
2481   }
2482   return $remaining;
2483 }
2484
2485 =item reexport
2486
2487 This method is deprecated.  See the I<depend_jobnum> option to the insert and
2488 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2489
2490 =cut
2491
2492 sub reexport {
2493   my $self = shift;
2494
2495   local $SIG{HUP} = 'IGNORE';
2496   local $SIG{INT} = 'IGNORE';
2497   local $SIG{QUIT} = 'IGNORE';
2498   local $SIG{TERM} = 'IGNORE';
2499   local $SIG{TSTP} = 'IGNORE';
2500   local $SIG{PIPE} = 'IGNORE';
2501
2502   my $oldAutoCommit = $FS::UID::AutoCommit;
2503   local $FS::UID::AutoCommit = 0;
2504   my $dbh = dbh;
2505
2506   foreach my $cust_svc ( $self->cust_svc ) {
2507     #false laziness w/svc_Common::insert
2508     my $svc_x = $cust_svc->svc_x;
2509     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2510       my $error = $part_export->export_insert($svc_x);
2511       if ( $error ) {
2512         $dbh->rollback if $oldAutoCommit;
2513         return $error;
2514       }
2515     }
2516   }
2517
2518   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2519   '';
2520
2521 }
2522
2523 =item insert_reason
2524
2525 Associates this package with a (suspension or cancellation) reason (see
2526 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2527 L<FS::reason>).
2528
2529 Available options are:
2530
2531 =over 4
2532
2533 =item reason
2534
2535 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.
2536
2537 =item reason_otaker
2538
2539 the access_user (see L<FS::access_user>) providing the reason
2540
2541 =item date
2542
2543 a unix timestamp 
2544
2545 =item action
2546
2547 the action (cancel, susp, adjourn, expire) associated with the reason
2548
2549 =back
2550
2551 If there is an error, returns the error, otherwise returns false.
2552
2553 =cut
2554
2555 sub insert_reason {
2556   my ($self, %options) = @_;
2557
2558   my $otaker = $options{reason_otaker} ||
2559                $FS::CurrentUser::CurrentUser->username;
2560
2561   my $reasonnum;
2562   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2563
2564     $reasonnum = $1;
2565
2566   } elsif ( ref($options{'reason'}) ) {
2567   
2568     return 'Enter a new reason (or select an existing one)'
2569       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2570
2571     my $reason = new FS::reason({
2572       'reason_type' => $options{'reason'}->{'typenum'},
2573       'reason'      => $options{'reason'}->{'reason'},
2574     });
2575     my $error = $reason->insert;
2576     return $error if $error;
2577
2578     $reasonnum = $reason->reasonnum;
2579
2580   } else {
2581     return "Unparsable reason: ". $options{'reason'};
2582   }
2583
2584   my $cust_pkg_reason =
2585     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2586                               'reasonnum' => $reasonnum, 
2587                               'otaker'    => $otaker,
2588                               'action'    => substr(uc($options{'action'}),0,1),
2589                               'date'      => $options{'date'}
2590                                                ? $options{'date'}
2591                                                : time,
2592                             });
2593
2594   $cust_pkg_reason->insert;
2595 }
2596
2597 =item insert_discount
2598
2599 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
2600 inserting a new discount on the fly (see L<FS::discount>).
2601
2602 Available options are:
2603
2604 =over 4
2605
2606 =item discountnum
2607
2608 =back
2609
2610 If there is an error, returns the error, otherwise returns false.
2611
2612 =cut
2613
2614 sub insert_discount {
2615   #my ($self, %options) = @_;
2616   my $self = shift;
2617
2618   my $cust_pkg_discount = new FS::cust_pkg_discount {
2619     'pkgnum'      => $self->pkgnum,
2620     'discountnum' => $self->discountnum,
2621     'months_used' => 0,
2622     'end_date'    => '', #XXX
2623     #for the create a new discount case
2624     '_type'       => $self->discountnum__type,
2625     'amount'      => $self->discountnum_amount,
2626     'percent'     => $self->discountnum_percent,
2627     'months'      => $self->discountnum_months,
2628     'setup'      => $self->discountnum_setup,
2629     #'disabled'    => $self->discountnum_disabled,
2630   };
2631
2632   $cust_pkg_discount->insert;
2633 }
2634
2635 =item set_usage USAGE_VALUE_HASHREF 
2636
2637 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2638 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2639 upbytes, downbytes, and totalbytes are appropriate keys.
2640
2641 All svc_accts which are part of this package have their values reset.
2642
2643 =cut
2644
2645 sub set_usage {
2646   my ($self, $valueref, %opt) = @_;
2647
2648   foreach my $cust_svc ($self->cust_svc){
2649     my $svc_x = $cust_svc->svc_x;
2650     $svc_x->set_usage($valueref, %opt)
2651       if $svc_x->can("set_usage");
2652   }
2653 }
2654
2655 =item recharge USAGE_VALUE_HASHREF 
2656
2657 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2658 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2659 upbytes, downbytes, and totalbytes are appropriate keys.
2660
2661 All svc_accts which are part of this package have their values incremented.
2662
2663 =cut
2664
2665 sub recharge {
2666   my ($self, $valueref) = @_;
2667
2668   foreach my $cust_svc ($self->cust_svc){
2669     my $svc_x = $cust_svc->svc_x;
2670     $svc_x->recharge($valueref)
2671       if $svc_x->can("recharge");
2672   }
2673 }
2674
2675 =item cust_pkg_discount
2676
2677 =cut
2678
2679 sub cust_pkg_discount {
2680   my $self = shift;
2681   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
2682 }
2683
2684 =item cust_pkg_discount_active
2685
2686 =cut
2687
2688 sub cust_pkg_discount_active {
2689   my $self = shift;
2690   grep { $_->status eq 'active' } $self->cust_pkg_discount;
2691 }
2692
2693 =back
2694
2695 =head1 CLASS METHODS
2696
2697 =over 4
2698
2699 =item recurring_sql
2700
2701 Returns an SQL expression identifying recurring packages.
2702
2703 =cut
2704
2705 sub recurring_sql { "
2706   '0' != ( select freq from part_pkg
2707              where cust_pkg.pkgpart = part_pkg.pkgpart )
2708 "; }
2709
2710 =item onetime_sql
2711
2712 Returns an SQL expression identifying one-time packages.
2713
2714 =cut
2715
2716 sub onetime_sql { "
2717   '0' = ( select freq from part_pkg
2718             where cust_pkg.pkgpart = part_pkg.pkgpart )
2719 "; }
2720
2721 =item ordered_sql
2722
2723 Returns an SQL expression identifying ordered packages (recurring packages not
2724 yet billed).
2725
2726 =cut
2727
2728 sub ordered_sql {
2729    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
2730 }
2731
2732 =item active_sql
2733
2734 Returns an SQL expression identifying active packages.
2735
2736 =cut
2737
2738 sub active_sql {
2739   $_[0]->recurring_sql. "
2740   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2741   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2742   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2743 "; }
2744
2745 =item not_yet_billed_sql
2746
2747 Returns an SQL expression identifying packages which have not yet been billed.
2748
2749 =cut
2750
2751 sub not_yet_billed_sql { "
2752       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
2753   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2754   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2755 "; }
2756
2757 =item inactive_sql
2758
2759 Returns an SQL expression identifying inactive packages (one-time packages
2760 that are otherwise unsuspended/uncancelled).
2761
2762 =cut
2763
2764 sub inactive_sql { "
2765   ". $_[0]->onetime_sql(). "
2766   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2767   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2768   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2769 "; }
2770
2771 =item susp_sql
2772 =item suspended_sql
2773
2774 Returns an SQL expression identifying suspended packages.
2775
2776 =cut
2777
2778 sub suspended_sql { susp_sql(@_); }
2779 sub susp_sql {
2780   #$_[0]->recurring_sql(). ' AND '.
2781   "
2782         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
2783     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
2784   ";
2785 }
2786
2787 =item cancel_sql
2788 =item cancelled_sql
2789
2790 Returns an SQL exprression identifying cancelled packages.
2791
2792 =cut
2793
2794 sub cancelled_sql { cancel_sql(@_); }
2795 sub cancel_sql { 
2796   #$_[0]->recurring_sql(). ' AND '.
2797   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2798 }
2799
2800 =item status_sql
2801
2802 Returns an SQL expression to give the package status as a string.
2803
2804 =cut
2805
2806 sub status_sql {
2807 "CASE
2808   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
2809   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
2810   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
2811   WHEN ".onetime_sql()." THEN 'one-time charge'
2812   ELSE 'active'
2813 END"
2814 }
2815
2816 =item search HASHREF
2817
2818 (Class method)
2819
2820 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2821 Valid parameters are
2822
2823 =over 4
2824
2825 =item agentnum
2826
2827 =item magic
2828
2829 active, inactive, suspended, cancel (or cancelled)
2830
2831 =item status
2832
2833 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2834
2835 =item custom
2836
2837  boolean selects custom packages
2838
2839 =item classnum
2840
2841 =item pkgpart
2842
2843 pkgpart or arrayref or hashref of pkgparts
2844
2845 =item setup
2846
2847 arrayref of beginning and ending epoch date
2848
2849 =item last_bill
2850
2851 arrayref of beginning and ending epoch date
2852
2853 =item bill
2854
2855 arrayref of beginning and ending epoch date
2856
2857 =item adjourn
2858
2859 arrayref of beginning and ending epoch date
2860
2861 =item susp
2862
2863 arrayref of beginning and ending epoch date
2864
2865 =item expire
2866
2867 arrayref of beginning and ending epoch date
2868
2869 =item cancel
2870
2871 arrayref of beginning and ending epoch date
2872
2873 =item query
2874
2875 pkgnum or APKG_pkgnum
2876
2877 =item cust_fields
2878
2879 a value suited to passing to FS::UI::Web::cust_header
2880
2881 =item CurrentUser
2882
2883 specifies the user for agent virtualization
2884
2885 =item fcc_line
2886
2887  boolean selects packages containing fcc form 477 telco lines
2888
2889 =back
2890
2891 =cut
2892
2893 sub search {
2894   my ($class, $params) = @_;
2895   my @where = ();
2896
2897   ##
2898   # parse agent
2899   ##
2900
2901   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2902     push @where,
2903       "cust_main.agentnum = $1";
2904   }
2905
2906   ##
2907   # parse custnum
2908   ##
2909
2910   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2911     push @where,
2912       "cust_pkg.custnum = $1";
2913   }
2914
2915   ##
2916   # custbatch
2917   ##
2918
2919   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
2920     push @where,
2921       "cust_pkg.pkgbatch = '$1'";
2922   }
2923
2924   ##
2925   # parse status
2926   ##
2927
2928   if (    $params->{'magic'}  eq 'active'
2929        || $params->{'status'} eq 'active' ) {
2930
2931     push @where, FS::cust_pkg->active_sql();
2932
2933   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
2934             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
2935
2936     push @where, FS::cust_pkg->not_yet_billed_sql();
2937
2938   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
2939             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2940
2941     push @where, FS::cust_pkg->inactive_sql();
2942
2943   } elsif (    $params->{'magic'}  eq 'suspended'
2944             || $params->{'status'} eq 'suspended'  ) {
2945
2946     push @where, FS::cust_pkg->suspended_sql();
2947
2948   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
2949             || $params->{'status'} =~ /^cancell?ed$/ ) {
2950
2951     push @where, FS::cust_pkg->cancelled_sql();
2952
2953   }
2954
2955   ###
2956   # parse package class
2957   ###
2958
2959   if ( exists($params->{'classnum'}) ) {
2960
2961     my @classnum = ();
2962     if ( ref($params->{'classnum'}) ) {
2963
2964       if ( ref($params->{'classnum'}) eq 'HASH' ) {
2965         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
2966       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
2967         @classnum = @{ $params->{'classnum'} };
2968       } else {
2969         die 'unhandled classnum ref '. $params->{'classnum'};
2970       }
2971
2972
2973     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
2974       @classnum = ( $1 );
2975     }
2976
2977     if ( @classnum ) {
2978
2979       my @c_where = ();
2980       my @nums = grep $_, @classnum;
2981       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
2982       my $null = scalar( grep { $_ eq '' } @classnum );
2983       push @c_where, 'part_pkg.classnum IS NULL' if $null;
2984
2985       if ( scalar(@c_where) == 1 ) {
2986         push @where, @c_where;
2987       } elsif ( @c_where ) {
2988         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
2989       }
2990       warn $where[-1];
2991
2992     }
2993     
2994
2995   }
2996
2997   ###
2998   # parse package report options
2999   ###
3000
3001   my @report_option = ();
3002   if ( exists($params->{'report_option'}) ) {
3003     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
3004       @report_option = @{ $params->{'report_option'} };
3005     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
3006       @report_option = split(',', $1);
3007     }
3008
3009   }
3010
3011   if (@report_option) {
3012     # this will result in the empty set for the dangling comma case as it should
3013     push @where, 
3014       map{ "0 < ( SELECT count(*) FROM part_pkg_option
3015                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3016                     AND optionname = 'report_option_$_'
3017                     AND optionvalue = '1' )"
3018          } @report_option;
3019   }
3020
3021   foreach my $any ( grep /^report_option_any/, keys %$params ) {
3022
3023     my @report_option_any = ();
3024     if ( ref($params->{$any}) eq 'ARRAY' ) {
3025       @report_option_any = @{ $params->{$any} };
3026     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
3027       @report_option_any = split(',', $1);
3028     }
3029
3030     if (@report_option_any) {
3031       # this will result in the empty set for the dangling comma case as it should
3032       push @where, ' ( '. join(' OR ',
3033         map{ "0 < ( SELECT count(*) FROM part_pkg_option
3034                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3035                       AND optionname = 'report_option_$_'
3036                       AND optionvalue = '1' )"
3037            } @report_option_any
3038       ). ' ) ';
3039     }
3040
3041   }
3042
3043   ###
3044   # parse custom
3045   ###
3046
3047   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
3048
3049   ###
3050   # parse fcc_line
3051   ###
3052
3053   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
3054                                                         if $params->{fcc_line};
3055
3056   ###
3057   # parse censustract
3058   ###
3059
3060   if ( exists($params->{'censustract'}) ) {
3061     $params->{'censustract'} =~ /^([.\d]*)$/;
3062     my $censustract = "cust_main.censustract = '$1'";
3063     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
3064     push @where,  "( $censustract )";
3065   }
3066
3067   ###
3068   # parse censustract2
3069   ###
3070   if ( exists($params->{'censustract2'})
3071        && $params->{'censustract2'} =~ /^(\d*)$/
3072      )
3073   {
3074     if ($1) {
3075       push @where, "cust_main.censustract LIKE '$1%'";
3076     } else {
3077       push @where,
3078         "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
3079     }
3080   }
3081
3082   ###
3083   # parse part_pkg
3084   ###
3085
3086   if ( ref($params->{'pkgpart'}) ) {
3087
3088     my @pkgpart = ();
3089     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
3090       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
3091     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
3092       @pkgpart = @{ $params->{'pkgpart'} };
3093     } else {
3094       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
3095     }
3096
3097     @pkgpart = grep /^(\d+)$/, @pkgpart;
3098
3099     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
3100
3101   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3102     push @where, "pkgpart = $1";
3103   } 
3104
3105   ###
3106   # parse dates
3107   ###
3108
3109   my $orderby = '';
3110
3111   #false laziness w/report_cust_pkg.html
3112   my %disable = (
3113     'all'             => {},
3114     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
3115     'active'          => { 'susp'=>1, 'cancel'=>1 },
3116     'suspended'       => { 'cancel' => 1 },
3117     'cancelled'       => {},
3118     ''                => {},
3119   );
3120
3121   if( exists($params->{'active'} ) ) {
3122     # This overrides all the other date-related fields
3123     my($beginning, $ending) = @{$params->{'active'}};
3124     push @where,
3125       "cust_pkg.setup IS NOT NULL",
3126       "cust_pkg.setup <= $ending",
3127       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
3128       "NOT (".FS::cust_pkg->onetime_sql . ")";
3129   }
3130   else {
3131     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
3132
3133       next unless exists($params->{$field});
3134
3135       my($beginning, $ending) = @{$params->{$field}};
3136
3137       next if $beginning == 0 && $ending == 4294967295;
3138
3139       push @where,
3140         "cust_pkg.$field IS NOT NULL",
3141         "cust_pkg.$field >= $beginning",
3142         "cust_pkg.$field <= $ending";
3143
3144       $orderby ||= "ORDER BY cust_pkg.$field";
3145
3146     }
3147   }
3148
3149   $orderby ||= 'ORDER BY bill';
3150
3151   ###
3152   # parse magic, legacy, etc.
3153   ###
3154
3155   if ( $params->{'magic'} &&
3156        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
3157   ) {
3158
3159     $orderby = 'ORDER BY pkgnum';
3160
3161     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3162       push @where, "pkgpart = $1";
3163     }
3164
3165   } elsif ( $params->{'query'} eq 'pkgnum' ) {
3166
3167     $orderby = 'ORDER BY pkgnum';
3168
3169   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
3170
3171     $orderby = 'ORDER BY pkgnum';
3172
3173     push @where, '0 < (
3174       SELECT count(*) FROM pkg_svc
3175        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
3176          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
3177                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
3178                                      AND cust_svc.svcpart = pkg_svc.svcpart
3179                                 )
3180     )';
3181   
3182   }
3183
3184   ##
3185   # setup queries, links, subs, etc. for the search
3186   ##
3187
3188   # here is the agent virtualization
3189   if ($params->{CurrentUser}) {
3190     my $access_user =
3191       qsearchs('access_user', { username => $params->{CurrentUser} });
3192
3193     if ($access_user) {
3194       push @where, $access_user->agentnums_sql('table'=>'cust_main');
3195     } else {
3196       push @where, "1=0";
3197     }
3198   } else {
3199     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
3200   }
3201
3202   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
3203
3204   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
3205                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
3206                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
3207
3208   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
3209
3210   my $sql_query = {
3211     'table'       => 'cust_pkg',
3212     'hashref'     => {},
3213     'select'      => join(', ',
3214                                 'cust_pkg.*',
3215                                 ( map "part_pkg.$_", qw( pkg freq ) ),
3216                                 'pkg_class.classname',
3217                                 'cust_main.custnum AS cust_main_custnum',
3218                                 FS::UI::Web::cust_sql_fields(
3219                                   $params->{'cust_fields'}
3220                                 ),
3221                      ),
3222     'extra_sql'   => "$extra_sql $orderby",
3223     'addl_from'   => $addl_from,
3224     'count_query' => $count_query,
3225   };
3226
3227 }
3228
3229 =item fcc_477_count
3230
3231 Returns a list of two package counts.  The first is a count of packages
3232 based on the supplied criteria and the second is the count of residential
3233 packages with those same criteria.  Criteria are specified as in the search
3234 method.
3235
3236 =cut
3237
3238 sub fcc_477_count {
3239   my ($class, $params) = @_;
3240
3241   my $sql_query = $class->search( $params );
3242
3243   my $count_sql = delete($sql_query->{'count_query'});
3244   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
3245     or die "couldn't parse count_sql";
3246
3247   my $count_sth = dbh->prepare($count_sql)
3248     or die "Error preparing $count_sql: ". dbh->errstr;
3249   $count_sth->execute
3250     or die "Error executing $count_sql: ". $count_sth->errstr;
3251   my $count_arrayref = $count_sth->fetchrow_arrayref;
3252
3253   return ( @$count_arrayref );
3254
3255 }
3256
3257
3258 =item location_sql
3259
3260 Returns a list: the first item is an SQL fragment identifying matching 
3261 packages/customers via location (taking into account shipping and package
3262 address taxation, if enabled), and subsequent items are the parameters to
3263 substitute for the placeholders in that fragment.
3264
3265 =cut
3266
3267 sub location_sql {
3268   my($class, %opt) = @_;
3269   my $ornull = $opt{'ornull'};
3270
3271   my $conf = new FS::Conf;
3272
3273   # '?' placeholders in _location_sql_where
3274   my $x = $ornull ? 3 : 2;
3275   my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
3276
3277   my $main_where;
3278   my @main_param;
3279   if ( $conf->exists('tax-ship_address') ) {
3280
3281     $main_where = "(
3282          (     ( ship_last IS NULL     OR  ship_last  = '' )
3283            AND ". _location_sql_where('cust_main', '', $ornull ). "
3284          )
3285       OR (       ship_last IS NOT NULL AND ship_last != ''
3286            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
3287          )
3288     )";
3289     #    AND payby != 'COMP'
3290
3291     @main_param = ( @bill_param, @bill_param );
3292
3293   } else {
3294
3295     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
3296     @main_param = @bill_param;
3297
3298   }
3299
3300   my $where;
3301   my @param;
3302   if ( $conf->exists('tax-pkg_address') ) {
3303
3304     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
3305
3306     $where = " (
3307                     ( cust_pkg.locationnum IS     NULL AND $main_where )
3308                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
3309                )
3310              ";
3311     @param = ( @main_param, @bill_param );
3312   
3313   } else {
3314
3315     $where = $main_where;
3316     @param = @main_param;
3317
3318   }
3319
3320   ( $where, @param );
3321
3322 }
3323
3324 #subroutine, helper for location_sql
3325 sub _location_sql_where {
3326   my $table  = shift;
3327   my $prefix = @_ ? shift : '';
3328   my $ornull = @_ ? shift : '';
3329
3330 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
3331
3332   $ornull = $ornull ? ' OR ? IS NULL ' : '';
3333
3334   my $or_empty_city   = " OR ( ? = '' AND $table.${prefix}city   IS NULL ) ";
3335   my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
3336   my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
3337
3338 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
3339   "
3340         ( $table.${prefix}city    = ? OR ? = '' OR CAST(? AS text) IS NULL )
3341     AND ( $table.${prefix}county  = ? $or_empty_county $ornull )
3342     AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
3343     AND   $table.${prefix}country = ?
3344   ";
3345 }
3346
3347 sub _X_show_zero {
3348   my( $self, $what ) = @_;
3349
3350   my $what_show_zero = $what. '_show_zero';
3351   length($self->$what_show_zero())
3352     ? ($self->$what_show_zero() eq 'Y')
3353     : $self->part_pkg->$what_show_zero();
3354 }
3355
3356 =head1 SUBROUTINES
3357
3358 =over 4
3359
3360 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
3361
3362 CUSTNUM is a customer (see L<FS::cust_main>)
3363
3364 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3365 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
3366 permitted.
3367
3368 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
3369 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
3370 new billing items.  An error is returned if this is not possible (see
3371 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
3372 parameter.
3373
3374 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3375 newly-created cust_pkg objects.
3376
3377 REFNUM, if specified, will specify the FS::pkg_referral record to be created
3378 and inserted.  Multiple FS::pkg_referral records can be created by
3379 setting I<refnum> to an array reference of refnums or a hash reference with
3380 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
3381 record will be created corresponding to cust_main.refnum.
3382
3383 =cut
3384
3385 sub order {
3386   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
3387
3388   my $conf = new FS::Conf;
3389
3390   # Transactionize this whole mess
3391   local $SIG{HUP} = 'IGNORE';
3392   local $SIG{INT} = 'IGNORE'; 
3393   local $SIG{QUIT} = 'IGNORE';
3394   local $SIG{TERM} = 'IGNORE';
3395   local $SIG{TSTP} = 'IGNORE'; 
3396   local $SIG{PIPE} = 'IGNORE'; 
3397
3398   my $oldAutoCommit = $FS::UID::AutoCommit;
3399   local $FS::UID::AutoCommit = 0;
3400   my $dbh = dbh;
3401
3402   my $error;
3403 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
3404 #  return "Customer not found: $custnum" unless $cust_main;
3405
3406   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
3407     if $DEBUG;
3408
3409   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3410                          @$remove_pkgnum;
3411
3412   my $change = scalar(@old_cust_pkg) != 0;
3413
3414   my %hash = (); 
3415   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
3416
3417     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
3418          " to pkgpart ". $pkgparts->[0]. "\n"
3419       if $DEBUG;
3420
3421     my $err_or_cust_pkg =
3422       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
3423                                 'refnum'  => $refnum,
3424                               );
3425
3426     unless (ref($err_or_cust_pkg)) {
3427       $dbh->rollback if $oldAutoCommit;
3428       return $err_or_cust_pkg;
3429     }
3430
3431     push @$return_cust_pkg, $err_or_cust_pkg;
3432     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3433     return '';
3434
3435   }
3436
3437   # Create the new packages.
3438   foreach my $pkgpart (@$pkgparts) {
3439
3440     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
3441
3442     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
3443                                       pkgpart => $pkgpart,
3444                                       refnum  => $refnum,
3445                                       %hash,
3446                                     };
3447     $error = $cust_pkg->insert( 'change' => $change );
3448     if ($error) {
3449       $dbh->rollback if $oldAutoCommit;
3450       return $error;
3451     }
3452     push @$return_cust_pkg, $cust_pkg;
3453   }
3454   # $return_cust_pkg now contains refs to all of the newly 
3455   # created packages.
3456
3457   # Transfer services and cancel old packages.
3458   foreach my $old_pkg (@old_cust_pkg) {
3459
3460     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
3461       if $DEBUG;
3462
3463     foreach my $new_pkg (@$return_cust_pkg) {
3464       $error = $old_pkg->transfer($new_pkg);
3465       if ($error and $error == 0) {
3466         # $old_pkg->transfer failed.
3467         $dbh->rollback if $oldAutoCommit;
3468         return $error;
3469       }
3470     }
3471
3472     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3473       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3474       foreach my $new_pkg (@$return_cust_pkg) {
3475         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3476         if ($error and $error == 0) {
3477           # $old_pkg->transfer failed.
3478         $dbh->rollback if $oldAutoCommit;
3479         return $error;
3480         }
3481       }
3482     }
3483
3484     if ($error > 0) {
3485       # Transfers were successful, but we went through all of the 
3486       # new packages and still had services left on the old package.
3487       # We can't cancel the package under the circumstances, so abort.
3488       $dbh->rollback if $oldAutoCommit;
3489       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3490     }
3491     $error = $old_pkg->cancel( quiet=>1 );
3492     if ($error) {
3493       $dbh->rollback;
3494       return $error;
3495     }
3496   }
3497   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3498   '';
3499 }
3500
3501 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3502
3503 A bulk change method to change packages for multiple customers.
3504
3505 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3506 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
3507 permitted.
3508
3509 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3510 replace.  The services (see L<FS::cust_svc>) are moved to the
3511 new billing items.  An error is returned if this is not possible (see
3512 L<FS::pkg_svc>).
3513
3514 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3515 newly-created cust_pkg objects.
3516
3517 =cut
3518
3519 sub bulk_change {
3520   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3521
3522   # Transactionize this whole mess
3523   local $SIG{HUP} = 'IGNORE';
3524   local $SIG{INT} = 'IGNORE'; 
3525   local $SIG{QUIT} = 'IGNORE';
3526   local $SIG{TERM} = 'IGNORE';
3527   local $SIG{TSTP} = 'IGNORE'; 
3528   local $SIG{PIPE} = 'IGNORE'; 
3529
3530   my $oldAutoCommit = $FS::UID::AutoCommit;
3531   local $FS::UID::AutoCommit = 0;
3532   my $dbh = dbh;
3533
3534   my @errors;
3535   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3536                          @$remove_pkgnum;
3537
3538   while(scalar(@old_cust_pkg)) {
3539     my @return = ();
3540     my $custnum = $old_cust_pkg[0]->custnum;
3541     my (@remove) = map { $_->pkgnum }
3542                    grep { $_->custnum == $custnum } @old_cust_pkg;
3543     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3544
3545     my $error = order $custnum, $pkgparts, \@remove, \@return;
3546
3547     push @errors, $error
3548       if $error;
3549     push @$return_cust_pkg, @return;
3550   }
3551
3552   if (scalar(@errors)) {
3553     $dbh->rollback if $oldAutoCommit;
3554     return join(' / ', @errors);
3555   }
3556
3557   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3558   '';
3559 }
3560
3561 # Used by FS::Upgrade to migrate to a new database.
3562 sub _upgrade_data {  # class method
3563   my ($class, %opts) = @_;
3564   $class->_upgrade_otaker(%opts);
3565   my @statements = (
3566     # RT#10139, bug resulting in contract_end being set when it shouldn't
3567   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
3568     # RT#10830, bad calculation of prorate date near end of year
3569     # the date range for bill is December 2009, and we move it forward
3570     # one year if it's before the previous bill date (which it should 
3571     # never be)
3572   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
3573   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
3574   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
3575     # RT6628, add order_date to cust_pkg
3576     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
3577         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
3578         history_action = \'insert\') where order_date is null',
3579   );
3580   foreach my $sql (@statements) {
3581     my $sth = dbh->prepare($sql);
3582     $sth->execute or die $sth->errstr;
3583   }
3584 }
3585
3586 =back
3587
3588 =head1 BUGS
3589
3590 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
3591
3592 In sub order, the @pkgparts array (passed by reference) is clobbered.
3593
3594 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
3595 method to pass dates to the recur_prog expression, it should do so.
3596
3597 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3598 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3599 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3600 configuration values.  Probably need a subroutine which decides what to do
3601 based on whether or not we've fetched the user yet, rather than a hash.  See
3602 FS::UID and the TODO.
3603
3604 Now that things are transactional should the check in the insert method be
3605 moved to check ?
3606
3607 =head1 SEE ALSO
3608
3609 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3610 L<FS::pkg_svc>, schema.html from the base documentation
3611
3612 =cut
3613
3614 1;
3615