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