payment terms localiation, #71549
[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 [, LOCALE ] ] ]
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 If LOCALE is passed, service definition names will be localized.
3845
3846 Returns a list of lists, calling the label method for all (historical)
3847 services (see L<FS::h_cust_svc>) of this billing item.
3848
3849 =cut
3850
3851 sub h_labels {
3852   my $self = shift;
3853   my ($end, $start, $mode, $locale) = @_;
3854   warn "$me h_labels\n"
3855     if $DEBUG;
3856   map { [ $_->label($end, $start, $locale) ] }
3857         $self->h_cust_svc($end, $start, $mode);
3858 }
3859
3860 =item labels_short
3861
3862 Like labels, except returns a simple flat list, and shortens long
3863 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3864 identical services to one line that lists the service label and the number of
3865 individual services rather than individual items.
3866
3867 =cut
3868
3869 sub labels_short {
3870   shift->_labels_short( 'labels' ); # 'labels' takes no further arguments
3871 }
3872
3873 =item h_labels_short END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ]
3874
3875 Like h_labels, except returns a simple flat list, and shortens long
3876 (currently >5 or the cust_bill-max_same_services configuration value) lists
3877 of identical services to one line that lists the service label and the
3878 number of individual services rather than individual items.
3879
3880 =cut
3881
3882 sub h_labels_short {
3883   shift->_labels_short( 'h_labels', @_ );
3884 }
3885
3886 # takes a method name ('labels' or 'h_labels') and all its arguments;
3887 # maybe should be "shorten($self->h_labels( ... ) )"
3888
3889 sub _labels_short {
3890   my( $self, $method ) = ( shift, shift );
3891
3892   warn "$me _labels_short called on $self with $method method\n"
3893     if $DEBUG;
3894
3895   my $conf = new FS::Conf;
3896   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3897
3898   warn "$me _labels_short populating \%labels\n"
3899     if $DEBUG;
3900
3901   my %labels;
3902   #tie %labels, 'Tie::IxHash';
3903   push @{ $labels{$_->[0]} }, $_->[1]
3904     foreach $self->$method(@_);
3905
3906   warn "$me _labels_short populating \@labels\n"
3907     if $DEBUG;
3908
3909   my @labels;
3910   foreach my $label ( keys %labels ) {
3911     my %seen = ();
3912     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3913     my $num = scalar(@values);
3914     warn "$me _labels_short $num items for $label\n"
3915       if $DEBUG;
3916
3917     if ( $num > $max_same_services ) {
3918       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3919         if $DEBUG;
3920       push @labels, "$label ($num)";
3921     } else {
3922       if ( $conf->exists('cust_bill-consolidate_services') ) {
3923         warn "$me _labels_short   consolidating services\n"
3924           if $DEBUG;
3925         # push @labels, "$label: ". join(', ', @values);
3926         while ( @values ) {
3927           my $detail = "$label: ";
3928           $detail .= shift(@values). ', '
3929             while @values
3930                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3931           $detail =~ s/, $//;
3932           push @labels, $detail;
3933         }
3934         warn "$me _labels_short   done consolidating services\n"
3935           if $DEBUG;
3936       } else {
3937         warn "$me _labels_short   adding service data\n"
3938           if $DEBUG;
3939         push @labels, map { "$label: $_" } @values;
3940       }
3941     }
3942   }
3943
3944  @labels;
3945
3946 }
3947
3948 =item cust_main
3949
3950 Returns the parent customer object (see L<FS::cust_main>).
3951
3952 =item balance
3953
3954 Returns the balance for this specific package, when using
3955 experimental package balance.
3956
3957 =cut
3958
3959 sub balance {
3960   my $self = shift;
3961   $self->cust_main->balance_pkgnum( $self->pkgnum );
3962 }
3963
3964 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3965
3966 =item cust_location
3967
3968 Returns the location object, if any (see L<FS::cust_location>).
3969
3970 =item cust_location_or_main
3971
3972 If this package is associated with a location, returns the locaiton (see
3973 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3974
3975 =item location_label [ OPTION => VALUE ... ]
3976
3977 Returns the label of the location object (see L<FS::cust_location>).
3978
3979 =cut
3980
3981 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3982
3983 =item tax_locationnum
3984
3985 Returns the foreign key to a L<FS::cust_location> object for calculating  
3986 tax on this package, as determined by the C<tax-pkg_address> and 
3987 C<tax-ship_address> configuration flags.
3988
3989 =cut
3990
3991 sub tax_locationnum {
3992   my $self = shift;
3993   my $conf = FS::Conf->new;
3994   if ( $conf->exists('tax-pkg_address') ) {
3995     return $self->locationnum;
3996   }
3997   elsif ( $conf->exists('tax-ship_address') ) {
3998     return $self->cust_main->ship_locationnum;
3999   }
4000   else {
4001     return $self->cust_main->bill_locationnum;
4002   }
4003 }
4004
4005 =item tax_location
4006
4007 Returns the L<FS::cust_location> object for tax_locationnum.
4008
4009 =cut
4010
4011 sub tax_location {
4012   my $self = shift;
4013   my $conf = FS::Conf->new;
4014   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
4015     return FS::cust_location->by_key($self->locationnum);
4016   }
4017   elsif ( $conf->exists('tax-ship_address') ) {
4018     return $self->cust_main->ship_location;
4019   }
4020   else {
4021     return $self->cust_main->bill_location;
4022   }
4023 }
4024
4025 =item seconds_since TIMESTAMP
4026
4027 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
4028 package have been online since TIMESTAMP, according to the session monitor.
4029
4030 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
4031 L<Time::Local> and L<Date::Parse> for conversion functions.
4032
4033 =cut
4034
4035 sub seconds_since {
4036   my($self, $since) = @_;
4037   my $seconds = 0;
4038
4039   foreach my $cust_svc (
4040     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
4041   ) {
4042     $seconds += $cust_svc->seconds_since($since);
4043   }
4044
4045   $seconds;
4046
4047 }
4048
4049 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
4050
4051 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
4052 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
4053 (exclusive).
4054
4055 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4056 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4057 functions.
4058
4059
4060 =cut
4061
4062 sub seconds_since_sqlradacct {
4063   my($self, $start, $end) = @_;
4064
4065   my $seconds = 0;
4066
4067   foreach my $cust_svc (
4068     grep {
4069       my $part_svc = $_->part_svc;
4070       $part_svc->svcdb eq 'svc_acct'
4071         && scalar($part_svc->part_export_usage);
4072     } $self->cust_svc
4073   ) {
4074     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
4075   }
4076
4077   $seconds;
4078
4079 }
4080
4081 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
4082
4083 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
4084 in this package for sessions ending between TIMESTAMP_START (inclusive) and
4085 TIMESTAMP_END
4086 (exclusive).
4087
4088 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4089 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4090 functions.
4091
4092 =cut
4093
4094 sub attribute_since_sqlradacct {
4095   my($self, $start, $end, $attrib) = @_;
4096
4097   my $sum = 0;
4098
4099   foreach my $cust_svc (
4100     grep {
4101       my $part_svc = $_->part_svc;
4102       scalar($part_svc->part_export_usage);
4103     } $self->cust_svc
4104   ) {
4105     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
4106   }
4107
4108   $sum;
4109
4110 }
4111
4112 =item quantity
4113
4114 =cut
4115
4116 sub quantity {
4117   my( $self, $value ) = @_;
4118   if ( defined($value) ) {
4119     $self->setfield('quantity', $value);
4120   }
4121   $self->getfield('quantity') || 1;
4122 }
4123
4124 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
4125
4126 Transfers as many services as possible from this package to another package.
4127
4128 The destination package can be specified by pkgnum by passing an FS::cust_pkg
4129 object.  The destination package must already exist.
4130
4131 Services are moved only if the destination allows services with the correct
4132 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
4133 this option with caution!  No provision is made for export differences
4134 between the old and new service definitions.  Probably only should be used
4135 when your exports for all service definitions of a given svcdb are identical.
4136 (attempt a transfer without it first, to move all possible svcpart-matching
4137 services)
4138
4139 Any services that can't be moved remain in the original package.
4140
4141 Returns an error, if there is one; otherwise, returns the number of services 
4142 that couldn't be moved.
4143
4144 =cut
4145
4146 sub transfer {
4147   my ($self, $dest_pkgnum, %opt) = @_;
4148
4149   my $remaining = 0;
4150   my $dest;
4151   my %target;
4152
4153   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
4154     $dest = $dest_pkgnum;
4155     $dest_pkgnum = $dest->pkgnum;
4156   } else {
4157     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
4158   }
4159
4160   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
4161
4162   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
4163     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
4164   }
4165
4166   foreach my $cust_svc ($dest->cust_svc) {
4167     $target{$cust_svc->svcpart}--;
4168   }
4169
4170   my %svcpart2svcparts = ();
4171   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4172     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
4173     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
4174       next if exists $svcpart2svcparts{$svcpart};
4175       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
4176       $svcpart2svcparts{$svcpart} = [
4177         map  { $_->[0] }
4178         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
4179         map {
4180               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
4181                                                    'svcpart' => $_          } );
4182               [ $_,
4183                 $pkg_svc ? $pkg_svc->primary_svc : '',
4184                 $pkg_svc ? $pkg_svc->quantity : 0,
4185               ];
4186             }
4187
4188         grep { $_ != $svcpart }
4189         map  { $_->svcpart }
4190         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
4191       ];
4192       warn "alternates for svcpart $svcpart: ".
4193            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
4194         if $DEBUG;
4195     }
4196   }
4197
4198   my $error;
4199   foreach my $cust_svc ($self->cust_svc) {
4200     my $svcnum = $cust_svc->svcnum;
4201     if($target{$cust_svc->svcpart} > 0
4202        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
4203       $target{$cust_svc->svcpart}--;
4204       my $new = new FS::cust_svc { $cust_svc->hash };
4205       $new->pkgnum($dest_pkgnum);
4206       $error = $new->replace($cust_svc);
4207     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4208       if ( $DEBUG ) {
4209         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
4210         warn "alternates to consider: ".
4211              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
4212       }
4213       my @alternate = grep {
4214                              warn "considering alternate svcpart $_: ".
4215                                   "$target{$_} available in new package\n"
4216                                if $DEBUG;
4217                              $target{$_} > 0;
4218                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
4219       if ( @alternate ) {
4220         warn "alternate(s) found\n" if $DEBUG;
4221         my $change_svcpart = $alternate[0];
4222         $target{$change_svcpart}--;
4223         my $new = new FS::cust_svc { $cust_svc->hash };
4224         $new->svcpart($change_svcpart);
4225         $new->pkgnum($dest_pkgnum);
4226         $error = $new->replace($cust_svc);
4227       } else {
4228         $remaining++;
4229       }
4230     } else {
4231       $remaining++
4232     }
4233     if ( $error ) {
4234       my @label = $cust_svc->label;
4235       return "$label[0] $label[1]: $error";
4236     }
4237   }
4238   return $remaining;
4239 }
4240
4241 =item grab_svcnums SVCNUM, SVCNUM ...
4242
4243 Change the pkgnum for the provided services to this packages.  If there is an
4244 error, returns the error, otherwise returns false.
4245
4246 =cut
4247
4248 sub grab_svcnums {
4249   my $self = shift;
4250   my @svcnum = @_;
4251
4252   my $oldAutoCommit = $FS::UID::AutoCommit;
4253   local $FS::UID::AutoCommit = 0;
4254   my $dbh = dbh;
4255
4256   foreach my $svcnum (@svcnum) {
4257     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4258       $dbh->rollback if $oldAutoCommit;
4259       return "unknown svcnum $svcnum";
4260     };
4261     $cust_svc->pkgnum( $self->pkgnum );
4262     my $error = $cust_svc->replace;
4263     if ( $error ) {
4264       $dbh->rollback if $oldAutoCommit;
4265       return $error;
4266     }
4267   }
4268
4269   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4270   '';
4271
4272 }
4273
4274 =item reexport
4275
4276 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4277 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4278
4279 =cut
4280
4281 #looks like this is still used by the order_pkg and change_pkg methods in
4282 # ClientAPI/MyAccount, need to look into those before removing
4283 sub reexport {
4284   my $self = shift;
4285
4286   my $oldAutoCommit = $FS::UID::AutoCommit;
4287   local $FS::UID::AutoCommit = 0;
4288   my $dbh = dbh;
4289
4290   foreach my $cust_svc ( $self->cust_svc ) {
4291     #false laziness w/svc_Common::insert
4292     my $svc_x = $cust_svc->svc_x;
4293     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4294       my $error = $part_export->export_insert($svc_x);
4295       if ( $error ) {
4296         $dbh->rollback if $oldAutoCommit;
4297         return $error;
4298       }
4299     }
4300   }
4301
4302   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4303   '';
4304
4305 }
4306
4307 =item export_pkg_change OLD_CUST_PKG
4308
4309 Calls the "pkg_change" export action for all services attached to this package.
4310
4311 =cut
4312
4313 sub export_pkg_change {
4314   my( $self, $old )  = ( shift, shift );
4315
4316   my $oldAutoCommit = $FS::UID::AutoCommit;
4317   local $FS::UID::AutoCommit = 0;
4318   my $dbh = dbh;
4319
4320   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4321     my $error = $svc_x->export('pkg_change', $self, $old);
4322     if ( $error ) {
4323       $dbh->rollback if $oldAutoCommit;
4324       return $error;
4325     }
4326   }
4327
4328   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4329   '';
4330
4331 }
4332
4333 =item insert_reason
4334
4335 Associates this package with a (suspension or cancellation) reason (see
4336 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4337 L<FS::reason>).
4338
4339 Available options are:
4340
4341 =over 4
4342
4343 =item reason
4344
4345 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.
4346
4347 =item reason_otaker
4348
4349 the access_user (see L<FS::access_user>) providing the reason
4350
4351 =item date
4352
4353 a unix timestamp 
4354
4355 =item action
4356
4357 the action (cancel, susp, adjourn, expire) associated with the reason
4358
4359 =back
4360
4361 If there is an error, returns the error, otherwise returns false.
4362
4363 =cut
4364
4365 sub insert_reason {
4366   my ($self, %options) = @_;
4367
4368   my $otaker = $options{reason_otaker} ||
4369                $FS::CurrentUser::CurrentUser->username;
4370
4371   my $reasonnum;
4372   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4373
4374     $reasonnum = $1;
4375
4376   } elsif ( ref($options{'reason'}) ) {
4377   
4378     return 'Enter a new reason (or select an existing one)'
4379       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4380
4381     my $reason = new FS::reason({
4382       'reason_type' => $options{'reason'}->{'typenum'},
4383       'reason'      => $options{'reason'}->{'reason'},
4384     });
4385     my $error = $reason->insert;
4386     return $error if $error;
4387
4388     $reasonnum = $reason->reasonnum;
4389
4390   } else {
4391     return "Unparseable reason: ". $options{'reason'};
4392   }
4393
4394   my $cust_pkg_reason =
4395     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4396                               'reasonnum' => $reasonnum, 
4397                               'otaker'    => $otaker,
4398                               'action'    => substr(uc($options{'action'}),0,1),
4399                               'date'      => $options{'date'}
4400                                                ? $options{'date'}
4401                                                : time,
4402                             });
4403
4404   $cust_pkg_reason->insert;
4405 }
4406
4407 =item insert_discount
4408
4409 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4410 inserting a new discount on the fly (see L<FS::discount>).
4411
4412 This will look at the cust_pkg for a pseudo-field named "setup_discountnum",
4413 and if present, will create a setup discount. If the discountnum is -1,
4414 a new discount definition will be inserted using the value in
4415 "setup_discountnum_amount" or "setup_discountnum_percent". Likewise for recur.
4416
4417 If there is an error, returns the error, otherwise returns false.
4418
4419 =cut
4420
4421 sub insert_discount {
4422   #my ($self, %options) = @_;
4423   my $self = shift;
4424
4425   foreach my $x (qw(setup recur)) {
4426     if ( my $discountnum = $self->get("${x}_discountnum") ) {
4427       my $cust_pkg_discount = FS::cust_pkg_discount->new( {
4428         'pkgnum'      => $self->pkgnum,
4429         'discountnum' => $discountnum,
4430         'setuprecur'  => $x,
4431         'months_used' => 0,
4432         'end_date'    => '', #XXX
4433         #for the create a new discount case
4434         'amount'      => $self->get("${x}_discountnum_amount"),
4435         'percent'     => $self->get("${x}_discountnum_percent"),
4436         'months'      => $self->get("${x}_discountnum_months"),
4437       } );
4438       if ( $x eq 'setup' ) {
4439         $cust_pkg_discount->setup('Y');
4440         $cust_pkg_discount->months('');
4441       }
4442       my $error = $cust_pkg_discount->insert;
4443       return $error if $error;
4444     }
4445   }
4446
4447   '';
4448 }
4449
4450 =item set_usage USAGE_VALUE_HASHREF 
4451
4452 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4453 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4454 upbytes, downbytes, and totalbytes are appropriate keys.
4455
4456 All svc_accts which are part of this package have their values reset.
4457
4458 =cut
4459
4460 sub set_usage {
4461   my ($self, $valueref, %opt) = @_;
4462
4463   #only svc_acct can set_usage for now
4464   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4465     my $svc_x = $cust_svc->svc_x;
4466     $svc_x->set_usage($valueref, %opt)
4467       if $svc_x->can("set_usage");
4468   }
4469 }
4470
4471 =item recharge USAGE_VALUE_HASHREF 
4472
4473 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4474 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4475 upbytes, downbytes, and totalbytes are appropriate keys.
4476
4477 All svc_accts which are part of this package have their values incremented.
4478
4479 =cut
4480
4481 sub recharge {
4482   my ($self, $valueref) = @_;
4483
4484   #only svc_acct can set_usage for now
4485   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4486     my $svc_x = $cust_svc->svc_x;
4487     $svc_x->recharge($valueref)
4488       if $svc_x->can("recharge");
4489   }
4490 }
4491
4492 =item apply_usageprice 
4493
4494 =cut
4495
4496 sub apply_usageprice {
4497   my $self = shift;
4498
4499   my $oldAutoCommit = $FS::UID::AutoCommit;
4500   local $FS::UID::AutoCommit = 0;
4501   my $dbh = dbh;
4502
4503   my $error = '';
4504
4505   foreach my $cust_pkg_usageprice ( $self->cust_pkg_usageprice ) {
4506     $error ||= $cust_pkg_usageprice->apply;
4507   }
4508
4509   if ( $error ) {
4510     $dbh->rollback if $oldAutoCommit;
4511     die "error applying part_pkg_usageprice add-ons, pkgnum ". $self->pkgnum.
4512         ": $error\n";
4513   } else {
4514     $dbh->commit if $oldAutoCommit;
4515   }
4516
4517
4518 }
4519
4520 =item cust_pkg_discount
4521
4522 =item cust_pkg_discount_active
4523
4524 =cut
4525
4526 sub cust_pkg_discount_active {
4527   my $self = shift;
4528   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4529 }
4530
4531 =item cust_pkg_usage
4532
4533 Returns a list of all voice usage counters attached to this package.
4534
4535 =item apply_usage OPTIONS
4536
4537 Takes the following options:
4538 - cdr: a call detail record (L<FS::cdr>)
4539 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4540 - minutes: the maximum number of minutes to be charged
4541
4542 Finds available usage minutes for a call of this class, and subtracts
4543 up to that many minutes from the usage pool.  If the usage pool is empty,
4544 and the C<cdr-minutes_priority> global config option is set, minutes may
4545 be taken from other calls as well.  Either way, an allocation record will
4546 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4547 number of minutes of usage applied to the call.
4548
4549 =cut
4550
4551 sub apply_usage {
4552   my ($self, %opt) = @_;
4553   my $cdr = $opt{cdr};
4554   my $rate_detail = $opt{rate_detail};
4555   my $minutes = $opt{minutes};
4556   my $classnum = $rate_detail->classnum;
4557   my $pkgnum = $self->pkgnum;
4558   my $custnum = $self->custnum;
4559
4560   my $oldAutoCommit = $FS::UID::AutoCommit;
4561   local $FS::UID::AutoCommit = 0;
4562   my $dbh = dbh;
4563
4564   my $order = FS::Conf->new->config('cdr-minutes_priority');
4565
4566   my $is_classnum;
4567   if ( $classnum ) {
4568     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4569   } else {
4570     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4571   }
4572   my @usage_recs = qsearch({
4573       'table'     => 'cust_pkg_usage',
4574       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4575                      ' JOIN cust_pkg             USING (pkgnum)'.
4576                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4577       'select'    => 'cust_pkg_usage.*',
4578       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4579                      " ( cust_pkg.custnum = $custnum AND ".
4580                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4581                      $is_classnum . ' AND '.
4582                      " cust_pkg_usage.minutes > 0",
4583       'order_by'  => " ORDER BY priority ASC",
4584   });
4585
4586   my $orig_minutes = $minutes;
4587   my $error;
4588   while (!$error and $minutes > 0 and @usage_recs) {
4589     my $cust_pkg_usage = shift @usage_recs;
4590     $cust_pkg_usage->select_for_update;
4591     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4592         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4593         acctid      => $cdr->acctid,
4594         minutes     => min($cust_pkg_usage->minutes, $minutes),
4595     });
4596     $cust_pkg_usage->set('minutes',
4597       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4598     );
4599     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4600     $minutes -= $cdr_cust_pkg_usage->minutes;
4601   }
4602   if ( $order and $minutes > 0 and !$error ) {
4603     # then try to steal minutes from another call
4604     my %search = (
4605         'table'     => 'cdr_cust_pkg_usage',
4606         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4607                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4608                        ' JOIN cust_pkg              USING (pkgnum)'.
4609                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4610                        ' JOIN cdr                   USING (acctid)',
4611         'select'    => 'cdr_cust_pkg_usage.*',
4612         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4613                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4614                        " ( cust_pkg.custnum = $custnum AND ".
4615                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4616                        " part_pkg_usage_class.classnum = $classnum",
4617         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4618     );
4619     if ( $order eq 'time' ) {
4620       # find CDRs that are using minutes, but have a later startdate
4621       # than this call
4622       my $startdate = $cdr->startdate;
4623       if ($startdate !~ /^\d+$/) {
4624         die "bad cdr startdate '$startdate'";
4625       }
4626       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4627       # minimize needless reshuffling
4628       $search{'order_by'} .= ', cdr.startdate DESC';
4629     } else {
4630       # XXX may not work correctly with rate_time schedules.  Could 
4631       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4632       # think...
4633       $search{'addl_from'} .=
4634         ' JOIN rate_detail'.
4635         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4636       if ( $order eq 'rate_high' ) {
4637         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4638                                 $rate_detail->min_charge;
4639         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4640       } elsif ( $order eq 'rate_low' ) {
4641         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4642                                 $rate_detail->min_charge;
4643         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4644       } else {
4645         #  this should really never happen
4646         die "invalid cdr-minutes_priority value '$order'\n";
4647       }
4648     }
4649     my @cdr_usage_recs = qsearch(\%search);
4650     my %reproc_cdrs;
4651     while (!$error and @cdr_usage_recs and $minutes > 0) {
4652       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4653       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4654       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4655       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4656       $cdr_cust_pkg_usage->select_for_update;
4657       $old_cdr->select_for_update;
4658       $cust_pkg_usage->select_for_update;
4659       # in case someone else stole the usage from this CDR
4660       # while waiting for the lock...
4661       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4662       # steal the usage allocation and flag the old CDR for reprocessing
4663       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4664       # if the allocation is more minutes than we need, adjust it...
4665       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4666       if ( $delta > 0 ) {
4667         $cdr_cust_pkg_usage->set('minutes', $minutes);
4668         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4669         $error = $cust_pkg_usage->replace;
4670       }
4671       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4672       $error ||= $cdr_cust_pkg_usage->replace;
4673       # deduct the stolen minutes
4674       $minutes -= $cdr_cust_pkg_usage->minutes;
4675     }
4676     # after all minute-stealing is done, reset the affected CDRs
4677     foreach (values %reproc_cdrs) {
4678       $error ||= $_->set_status('');
4679       # XXX or should we just call $cdr->rate right here?
4680       # it's not like we can create a loop this way, since the min_charge
4681       # or call time has to go monotonically in one direction.
4682       # we COULD get some very deep recursions going, though...
4683     }
4684   } # if $order and $minutes
4685   if ( $error ) {
4686     $dbh->rollback;
4687     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4688   } else {
4689     $dbh->commit if $oldAutoCommit;
4690     return $orig_minutes - $minutes;
4691   }
4692 }
4693
4694 =item supplemental_pkgs
4695
4696 Returns a list of all packages supplemental to this one.
4697
4698 =cut
4699
4700 sub supplemental_pkgs {
4701   my $self = shift;
4702   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4703 }
4704
4705 =item main_pkg
4706
4707 Returns the package that this one is supplemental to, if any.
4708
4709 =cut
4710
4711 sub main_pkg {
4712   my $self = shift;
4713   if ( $self->main_pkgnum ) {
4714     return FS::cust_pkg->by_key($self->main_pkgnum);
4715   }
4716   return;
4717 }
4718
4719 =back
4720
4721 =head1 CLASS METHODS
4722
4723 =over 4
4724
4725 =item recurring_sql
4726
4727 Returns an SQL expression identifying recurring packages.
4728
4729 =cut
4730
4731 sub recurring_sql { "
4732   '0' != ( select freq from part_pkg
4733              where cust_pkg.pkgpart = part_pkg.pkgpart )
4734 "; }
4735
4736 =item onetime_sql
4737
4738 Returns an SQL expression identifying one-time packages.
4739
4740 =cut
4741
4742 sub onetime_sql { "
4743   '0' = ( select freq from part_pkg
4744             where cust_pkg.pkgpart = part_pkg.pkgpart )
4745 "; }
4746
4747 =item ordered_sql
4748
4749 Returns an SQL expression identifying ordered packages (recurring packages not
4750 yet billed).
4751
4752 =cut
4753
4754 sub ordered_sql {
4755    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4756 }
4757
4758 =item active_sql
4759
4760 Returns an SQL expression identifying active packages.
4761
4762 =cut
4763
4764 sub active_sql {
4765   $_[0]->recurring_sql. "
4766   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4767   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4768   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4769 "; }
4770
4771 =item not_yet_billed_sql
4772
4773 Returns an SQL expression identifying packages which have not yet been billed.
4774
4775 =cut
4776
4777 sub not_yet_billed_sql { "
4778       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4779   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4780   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4781 "; }
4782
4783 =item inactive_sql
4784
4785 Returns an SQL expression identifying inactive packages (one-time packages
4786 that are otherwise unsuspended/uncancelled).
4787
4788 =cut
4789
4790 sub inactive_sql { "
4791   ". $_[0]->onetime_sql(). "
4792   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4793   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4794   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4795 "; }
4796
4797 =item on_hold_sql
4798
4799 Returns an SQL expression identifying on-hold packages.
4800
4801 =cut
4802
4803 sub on_hold_sql {
4804   #$_[0]->recurring_sql(). ' AND '.
4805   "
4806         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4807     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4808     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4809   ";
4810 }
4811
4812 =item susp_sql
4813 =item suspended_sql
4814
4815 Returns an SQL expression identifying suspended packages.
4816
4817 =cut
4818
4819 sub suspended_sql { susp_sql(@_); }
4820 sub susp_sql {
4821   #$_[0]->recurring_sql(). ' AND '.
4822   "
4823         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4824     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4825     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4826   ";
4827 }
4828
4829 =item cancel_sql
4830 =item cancelled_sql
4831
4832 Returns an SQL exprression identifying cancelled packages.
4833
4834 =cut
4835
4836 sub cancelled_sql { cancel_sql(@_); }
4837 sub cancel_sql { 
4838   #$_[0]->recurring_sql(). ' AND '.
4839   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4840 }
4841
4842 =item status_sql
4843
4844 Returns an SQL expression to give the package status as a string.
4845
4846 =cut
4847
4848 sub status_sql {
4849 "CASE
4850   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4851   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4852   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4853   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4854   WHEN ".onetime_sql()." THEN 'one-time charge'
4855   ELSE 'active'
4856 END"
4857 }
4858
4859 =item fcc_477_count
4860
4861 Returns a list of two package counts.  The first is a count of packages
4862 based on the supplied criteria and the second is the count of residential
4863 packages with those same criteria.  Criteria are specified as in the search
4864 method.
4865
4866 =cut
4867
4868 sub fcc_477_count {
4869   my ($class, $params) = @_;
4870
4871   my $sql_query = $class->search( $params );
4872
4873   my $count_sql = delete($sql_query->{'count_query'});
4874   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4875     or die "couldn't parse count_sql";
4876
4877   my $count_sth = dbh->prepare($count_sql)
4878     or die "Error preparing $count_sql: ". dbh->errstr;
4879   $count_sth->execute
4880     or die "Error executing $count_sql: ". $count_sth->errstr;
4881   my $count_arrayref = $count_sth->fetchrow_arrayref;
4882
4883   return ( @$count_arrayref );
4884
4885 }
4886
4887 =item tax_locationnum_sql
4888
4889 Returns an SQL expression for the tax location for a package, based
4890 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4891
4892 =cut
4893
4894 sub tax_locationnum_sql {
4895   my $conf = FS::Conf->new;
4896   if ( $conf->exists('tax-pkg_address') ) {
4897     'cust_pkg.locationnum';
4898   }
4899   elsif ( $conf->exists('tax-ship_address') ) {
4900     'cust_main.ship_locationnum';
4901   }
4902   else {
4903     'cust_main.bill_locationnum';
4904   }
4905 }
4906
4907 =item location_sql
4908
4909 Returns a list: the first item is an SQL fragment identifying matching 
4910 packages/customers via location (taking into account shipping and package
4911 address taxation, if enabled), and subsequent items are the parameters to
4912 substitute for the placeholders in that fragment.
4913
4914 =cut
4915
4916 sub location_sql {
4917   my($class, %opt) = @_;
4918   my $ornull = $opt{'ornull'};
4919
4920   my $conf = new FS::Conf;
4921
4922   # '?' placeholders in _location_sql_where
4923   my $x = $ornull ? 3 : 2;
4924   my @bill_param = ( 
4925     ('district')x3,
4926     ('city')x3, 
4927     ('county')x$x,
4928     ('state')x$x,
4929     'country'
4930   );
4931
4932   my $main_where;
4933   my @main_param;
4934   if ( $conf->exists('tax-ship_address') ) {
4935
4936     $main_where = "(
4937          (     ( ship_last IS NULL     OR  ship_last  = '' )
4938            AND ". _location_sql_where('cust_main', '', $ornull ). "
4939          )
4940       OR (       ship_last IS NOT NULL AND ship_last != ''
4941            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4942          )
4943     )";
4944     #    AND payby != 'COMP'
4945
4946     @main_param = ( @bill_param, @bill_param );
4947
4948   } else {
4949
4950     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4951     @main_param = @bill_param;
4952
4953   }
4954
4955   my $where;
4956   my @param;
4957   if ( $conf->exists('tax-pkg_address') ) {
4958
4959     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4960
4961     $where = " (
4962                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4963                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4964                )
4965              ";
4966     @param = ( @main_param, @bill_param );
4967   
4968   } else {
4969
4970     $where = $main_where;
4971     @param = @main_param;
4972
4973   }
4974
4975   ( $where, @param );
4976
4977 }
4978
4979 #subroutine, helper for location_sql
4980 sub _location_sql_where {
4981   my $table  = shift;
4982   my $prefix = @_ ? shift : '';
4983   my $ornull = @_ ? shift : '';
4984
4985 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4986
4987   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4988
4989   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4990   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4991   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4992
4993   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4994
4995 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4996   "
4997         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4998     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4999     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
5000     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
5001     AND   $table.${prefix}country  = ?
5002   ";
5003 }
5004
5005 sub _X_show_zero {
5006   my( $self, $what ) = @_;
5007
5008   my $what_show_zero = $what. '_show_zero';
5009   length($self->$what_show_zero())
5010     ? ($self->$what_show_zero() eq 'Y')
5011     : $self->part_pkg->$what_show_zero();
5012 }
5013
5014 =head1 SUBROUTINES
5015
5016 =over 4
5017
5018 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
5019
5020 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
5021 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
5022
5023 CUSTNUM is a customer (see L<FS::cust_main>)
5024
5025 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5026 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
5027 permitted.
5028
5029 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
5030 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
5031 new billing items.  An error is returned if this is not possible (see
5032 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
5033 parameter.
5034
5035 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5036 newly-created cust_pkg objects.
5037
5038 REFNUM, if specified, will specify the FS::pkg_referral record to be created
5039 and inserted.  Multiple FS::pkg_referral records can be created by
5040 setting I<refnum> to an array reference of refnums or a hash reference with
5041 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
5042 record will be created corresponding to cust_main.refnum.
5043
5044 =cut
5045
5046 sub order {
5047   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
5048
5049   my $conf = new FS::Conf;
5050
5051   # Transactionize this whole mess
5052   my $oldAutoCommit = $FS::UID::AutoCommit;
5053   local $FS::UID::AutoCommit = 0;
5054   my $dbh = dbh;
5055
5056   my $error;
5057 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
5058 #  return "Customer not found: $custnum" unless $cust_main;
5059
5060   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
5061     if $DEBUG;
5062
5063   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5064                          @$remove_pkgnum;
5065
5066   my $change = scalar(@old_cust_pkg) != 0;
5067
5068   my %hash = (); 
5069   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
5070
5071     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
5072          " to pkgpart ". $pkgparts->[0]. "\n"
5073       if $DEBUG;
5074
5075     my $err_or_cust_pkg =
5076       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5077                                 'refnum'  => $refnum,
5078                               );
5079
5080     unless (ref($err_or_cust_pkg)) {
5081       $dbh->rollback if $oldAutoCommit;
5082       return $err_or_cust_pkg;
5083     }
5084
5085     push @$return_cust_pkg, $err_or_cust_pkg;
5086     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5087     return '';
5088
5089   }
5090
5091   # Create the new packages.
5092   foreach my $pkgpart (@$pkgparts) {
5093
5094     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5095
5096     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5097                                       pkgpart => $pkgpart,
5098                                       refnum  => $refnum,
5099                                       %hash,
5100                                     };
5101     $error = $cust_pkg->insert( 'change' => $change );
5102     push @$return_cust_pkg, $cust_pkg;
5103
5104     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5105       my $supp_pkg = FS::cust_pkg->new({
5106           custnum => $custnum,
5107           pkgpart => $link->dst_pkgpart,
5108           refnum  => $refnum,
5109           main_pkgnum => $cust_pkg->pkgnum,
5110           %hash,
5111       });
5112       $error ||= $supp_pkg->insert( 'change' => $change );
5113       push @$return_cust_pkg, $supp_pkg;
5114     }
5115
5116     if ($error) {
5117       $dbh->rollback if $oldAutoCommit;
5118       return $error;
5119     }
5120
5121   }
5122   # $return_cust_pkg now contains refs to all of the newly 
5123   # created packages.
5124
5125   # Transfer services and cancel old packages.
5126   foreach my $old_pkg (@old_cust_pkg) {
5127
5128     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5129       if $DEBUG;
5130
5131     foreach my $new_pkg (@$return_cust_pkg) {
5132       $error = $old_pkg->transfer($new_pkg);
5133       if ($error and $error == 0) {
5134         # $old_pkg->transfer failed.
5135         $dbh->rollback if $oldAutoCommit;
5136         return $error;
5137       }
5138     }
5139
5140     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5141       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5142       foreach my $new_pkg (@$return_cust_pkg) {
5143         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5144         if ($error and $error == 0) {
5145           # $old_pkg->transfer failed.
5146         $dbh->rollback if $oldAutoCommit;
5147         return $error;
5148         }
5149       }
5150     }
5151
5152     if ($error > 0) {
5153       # Transfers were successful, but we went through all of the 
5154       # new packages and still had services left on the old package.
5155       # We can't cancel the package under the circumstances, so abort.
5156       $dbh->rollback if $oldAutoCommit;
5157       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5158     }
5159     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5160     if ($error) {
5161       $dbh->rollback;
5162       return $error;
5163     }
5164   }
5165   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5166   '';
5167 }
5168
5169 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5170
5171 A bulk change method to change packages for multiple customers.
5172
5173 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5174 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5175 permitted.
5176
5177 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5178 replace.  The services (see L<FS::cust_svc>) are moved to the
5179 new billing items.  An error is returned if this is not possible (see
5180 L<FS::pkg_svc>).
5181
5182 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5183 newly-created cust_pkg objects.
5184
5185 =cut
5186
5187 sub bulk_change {
5188   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5189
5190   # Transactionize this whole mess
5191   my $oldAutoCommit = $FS::UID::AutoCommit;
5192   local $FS::UID::AutoCommit = 0;
5193   my $dbh = dbh;
5194
5195   my @errors;
5196   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5197                          @$remove_pkgnum;
5198
5199   while(scalar(@old_cust_pkg)) {
5200     my @return = ();
5201     my $custnum = $old_cust_pkg[0]->custnum;
5202     my (@remove) = map { $_->pkgnum }
5203                    grep { $_->custnum == $custnum } @old_cust_pkg;
5204     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5205
5206     my $error = order $custnum, $pkgparts, \@remove, \@return;
5207
5208     push @errors, $error
5209       if $error;
5210     push @$return_cust_pkg, @return;
5211   }
5212
5213   if (scalar(@errors)) {
5214     $dbh->rollback if $oldAutoCommit;
5215     return join(' / ', @errors);
5216   }
5217
5218   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5219   '';
5220 }
5221
5222 =item forward_emails
5223
5224 Returns a hash of svcnums and corresponding email addresses
5225 for svc_acct services that can be used as source or dest
5226 for svc_forward services provisioned in this package.
5227
5228 Accepts options I<svc_forward> OR I<svcnum> for a svc_forward
5229 service;  if included, will ensure the current values of the
5230 specified service are included in the list, even if for some
5231 other reason they wouldn't be.  If called as a class method
5232 with a specified service, returns only these current values.
5233
5234 Caution: does not actually check if svc_forward services are
5235 available to be provisioned on this package.
5236
5237 =cut
5238
5239 sub forward_emails {
5240   my $self = shift;
5241   my %opt = @_;
5242
5243   #load optional service, thoroughly validated
5244   die "Use svcnum or svc_forward, not both"
5245     if $opt{'svcnum'} && $opt{'svc_forward'};
5246   my $svc_forward = $opt{'svc_forward'};
5247   $svc_forward ||= qsearchs('svc_forward',{ 'svcnum' => $opt{'svcnum'} })
5248     if $opt{'svcnum'};
5249   die "Specified service is not a forward service"
5250     if $svc_forward && (ref($svc_forward) ne 'FS::svc_forward');
5251   die "Specified service not found"
5252     if ($opt{'svcnum'} || $opt{'svc_forward'}) && !$svc_forward;
5253
5254   my %email;
5255
5256   ## everything below was basically copied from httemplate/edit/svc_forward.cgi 
5257   ## with minimal refactoring, not sure why we can't just load all svc_accts for this custnum
5258
5259   #add current values from specified service, if there was one
5260   if ($svc_forward) {
5261     foreach my $method (qw( srcsvc_acct dstsvc_acct )) {
5262       my $svc_acct = $svc_forward->$method();
5263       $email{$svc_acct->svcnum} = $svc_acct->email if $svc_acct;
5264     }
5265   }
5266
5267   if (ref($self) eq 'FS::cust_pkg') {
5268
5269     #and including the rest for this customer
5270     my($u_part_svc,@u_acct_svcparts);
5271     foreach $u_part_svc ( qsearch('part_svc',{'svcdb'=>'svc_acct'}) ) {
5272       push @u_acct_svcparts,$u_part_svc->getfield('svcpart');
5273     }
5274
5275     my $custnum = $self->getfield('custnum');
5276     foreach my $i_cust_pkg ( qsearch('cust_pkg',{'custnum'=>$custnum}) ) {
5277       my $cust_pkgnum = $i_cust_pkg->getfield('pkgnum');
5278       #now find the corresponding record(s) in cust_svc (for this pkgnum!)
5279       foreach my $acct_svcpart (@u_acct_svcparts) {
5280         foreach my $i_cust_svc (
5281           qsearch( 'cust_svc', { 'pkgnum'  => $cust_pkgnum,
5282                                  'svcpart' => $acct_svcpart } )
5283         ) {
5284           my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $i_cust_svc->svcnum } );
5285           $email{$svc_acct->svcnum} = $svc_acct->email;
5286         }  
5287       }
5288     }
5289   }
5290
5291   return %email;
5292 }
5293
5294 # Used by FS::Upgrade to migrate to a new database.
5295 sub _upgrade_data {  # class method
5296   my ($class, %opts) = @_;
5297   $class->_upgrade_otaker(%opts);
5298   my @statements = (
5299     # RT#10139, bug resulting in contract_end being set when it shouldn't
5300   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5301     # RT#10830, bad calculation of prorate date near end of year
5302     # the date range for bill is December 2009, and we move it forward
5303     # one year if it's before the previous bill date (which it should 
5304     # never be)
5305   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5306   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5307   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5308     # RT6628, add order_date to cust_pkg
5309     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5310         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5311         history_action = \'insert\') where order_date is null',
5312   );
5313   foreach my $sql (@statements) {
5314     my $sth = dbh->prepare($sql);
5315     $sth->execute or die $sth->errstr;
5316   }
5317
5318   # RT31194: supplemental package links that are deleted don't clean up 
5319   # linked records
5320   my @pkglinknums = qsearch({
5321       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5322       'table'     => 'cust_pkg',
5323       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5324       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5325                         AND part_pkg_link.pkglinknum IS NULL',
5326   });
5327   foreach (@pkglinknums) {
5328     my $pkglinknum = $_->pkglinknum;
5329     warn "cleaning part_pkg_link #$pkglinknum\n";
5330     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5331     my $error = $part_pkg_link->remove_linked;
5332     die $error if $error;
5333   }
5334 }
5335
5336 =back
5337
5338 =head1 BUGS
5339
5340 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5341
5342 In sub order, the @pkgparts array (passed by reference) is clobbered.
5343
5344 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5345 method to pass dates to the recur_prog expression, it should do so.
5346
5347 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5348 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5349 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5350 configuration values.  Probably need a subroutine which decides what to do
5351 based on whether or not we've fetched the user yet, rather than a hash.  See
5352 FS::UID and the TODO.
5353
5354 Now that things are transactional should the check in the insert method be
5355 moved to check ?
5356
5357 =head1 SEE ALSO
5358
5359 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5360 L<FS::pkg_svc>, schema.html from the base documentation
5361
5362 =cut
5363
5364 1;
5365