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