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