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