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