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