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