radacct update: use sqlradius for datasrc, not plandata options (whew)
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use vars qw(@ISA);
5 use FS::UID qw( getotaker dbh );
6 use FS::Record qw( qsearch qsearchs );
7 use FS::cust_svc;
8 use FS::part_pkg;
9 use FS::cust_main;
10 use FS::type_pkgs;
11 use FS::pkg_svc;
12 use FS::cust_bill_pkg;
13
14 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
15 # setup }
16 # because they load configuraion by setting FS::UID::callback (see TODO)
17 use FS::svc_acct;
18 use FS::svc_domain;
19 use FS::svc_www;
20 use FS::svc_forward;
21
22 @ISA = qw( FS::Record );
23
24 sub _cache {
25   my $self = shift;
26   my ( $hashref, $cache ) = @_;
27   #if ( $hashref->{'pkgpart'} ) {
28   if ( $hashref->{'pkg'} ) {
29     # #@{ $self->{'_pkgnum'} } = ();
30     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
31     # $self->{'_pkgpart'} = $subcache;
32     # #push @{ $self->{'_pkgnum'} },
33     #   FS::part_pkg->new_or_cached($hashref, $subcache);
34     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
35   }
36   if ( exists $hashref->{'svcnum'} ) {
37     #@{ $self->{'_pkgnum'} } = ();
38     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
39     $self->{'_svcnum'} = $subcache;
40     #push @{ $self->{'_pkgnum'} },
41     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
42   }
43 }
44
45 =head1 NAME
46
47 FS::cust_pkg - Object methods for cust_pkg objects
48
49 =head1 SYNOPSIS
50
51   use FS::cust_pkg;
52
53   $record = new FS::cust_pkg \%hash;
54   $record = new FS::cust_pkg { 'column' => 'value' };
55
56   $error = $record->insert;
57
58   $error = $new_record->replace($old_record);
59
60   $error = $record->delete;
61
62   $error = $record->check;
63
64   $error = $record->cancel;
65
66   $error = $record->suspend;
67
68   $error = $record->unsuspend;
69
70   $part_pkg = $record->part_pkg;
71
72   @labels = $record->labels;
73
74   $seconds = $record->seconds_since($timestamp);
75
76   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
77   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
78
79 =head1 DESCRIPTION
80
81 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
82 inherits from FS::Record.  The following fields are currently supported:
83
84 =over 4
85
86 =item pkgnum - primary key (assigned automatically for new billing items)
87
88 =item custnum - Customer (see L<FS::cust_main>)
89
90 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
91
92 =item setup - date
93
94 =item bill - date (next bill date)
95
96 =item susp - date
97
98 =item expire - date
99
100 =item cancel - date
101
102 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
103
104 =item manual_flag - If this field is set to 1, disables the automatic
105 unsuspension of this package when using the B<unsuspendauto> config file.
106
107 =back
108
109 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
110 see L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for
111 conversion functions.
112
113 =head1 METHODS
114
115 =over 4
116
117 =item new HASHREF
118
119 Create a new billing item.  To add the item to the database, see L<"insert">.
120
121 =cut
122
123 sub table { 'cust_pkg'; }
124
125 =item insert
126
127 Adds this billing item to the database ("Orders" the item).  If there is an
128 error, returns the error, otherwise returns false.
129
130 =cut
131
132 sub insert {
133   my $self = shift;
134
135   # custnum might not have have been defined in sub check (for one-shot new
136   # customers), so check it here instead
137   # (is this still necessary with transactions?)
138
139   my $error = $self->ut_number('custnum');
140   return $error if $error;
141
142   my $cust_main = $self->cust_main;
143   return "Unknown customer ". $self->custnum unless $cust_main;
144
145   my $agent = qsearchs( 'agent', { 'agentnum' => $cust_main->agentnum } );
146   my $pkgpart_href = $agent->pkgpart_hashref;
147   return "agent ". $agent->agentnum. " can't purchase pkgpart ". $self->pkgpart
148     unless $pkgpart_href->{ $self->pkgpart };
149
150   $self->SUPER::insert;
151
152 }
153
154 =item delete
155
156 This method now works but you probably shouldn't use it.
157
158 You don't want to delete billing items, because there would then be no record
159 the customer ever purchased the item.  Instead, see the cancel method.
160
161 =cut
162
163 #sub delete {
164 #  return "Can't delete cust_pkg records!";
165 #}
166
167 =item replace OLD_RECORD
168
169 Replaces the OLD_RECORD with this one in the database.  If there is an error,
170 returns the error, otherwise returns false.
171
172 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
173
174 Changing pkgpart may have disasterous effects.  See the order subroutine.
175
176 setup and bill are normally updated by calling the bill method of a customer
177 object (see L<FS::cust_main>).
178
179 suspend is normally updated by the suspend and unsuspend methods.
180
181 cancel is normally updated by the cancel method (and also the order subroutine
182 in some cases).
183
184 =cut
185
186 sub replace {
187   my( $new, $old ) = ( shift, shift );
188
189   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
190   return "Can't change otaker!" if $old->otaker ne $new->otaker;
191
192   #allow this *sigh*
193   #return "Can't change setup once it exists!"
194   #  if $old->getfield('setup') &&
195   #     $old->getfield('setup') != $new->getfield('setup');
196
197   #some logic for bill, susp, cancel?
198
199   $new->SUPER::replace($old);
200 }
201
202 =item check
203
204 Checks all fields to make sure this is a valid billing item.  If there is an
205 error, returns the error, otherwise returns false.  Called by the insert and
206 replace methods.
207
208 =cut
209
210 sub check {
211   my $self = shift;
212
213   my $error = 
214     $self->ut_numbern('pkgnum')
215     || $self->ut_numbern('custnum')
216     || $self->ut_number('pkgpart')
217     || $self->ut_numbern('setup')
218     || $self->ut_numbern('bill')
219     || $self->ut_numbern('susp')
220     || $self->ut_numbern('cancel')
221   ;
222   return $error if $error;
223
224   if ( $self->custnum ) { 
225     return "Unknown customer ". $self->custnum unless $self->cust_main;
226   }
227
228   return "Unknown pkgpart: ". $self->pkgpart
229     unless qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
230
231   $self->otaker(getotaker) unless $self->otaker;
232   $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
233   $self->otaker($1);
234
235   if ( $self->dbdef_table->column('manual_flag') ) {
236     $self->manual_flag =~ /^([01]?)$/ or return "Illegal manual_flag";
237     $self->manual_flag($1);
238   }
239
240   ''; #no error
241 }
242
243 =item cancel
244
245 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
246 in this package, then cancels the package itself (sets the cancel field to
247 now).
248
249 If there is an error, returns the error, otherwise returns false.
250
251 =cut
252
253 sub cancel {
254   my $self = shift;
255   my $error;
256
257   local $SIG{HUP} = 'IGNORE';
258   local $SIG{INT} = 'IGNORE';
259   local $SIG{QUIT} = 'IGNORE'; 
260   local $SIG{TERM} = 'IGNORE';
261   local $SIG{TSTP} = 'IGNORE';
262   local $SIG{PIPE} = 'IGNORE';
263
264   my $oldAutoCommit = $FS::UID::AutoCommit;
265   local $FS::UID::AutoCommit = 0;
266   my $dbh = dbh;
267
268   foreach my $cust_svc (
269     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
270   ) {
271     my $error = $cust_svc->cancel;
272
273     if ( $error ) {
274       $dbh->rollback if $oldAutoCommit;
275       return "Error cancelling cust_svc: $error";
276     }
277
278   }
279
280   unless ( $self->getfield('cancel') ) {
281     my %hash = $self->hash;
282     $hash{'cancel'} = time;
283     my $new = new FS::cust_pkg ( \%hash );
284     $error = $new->replace($self);
285     if ( $error ) {
286       $dbh->rollback if $oldAutoCommit;
287       return $error;
288     }
289   }
290
291   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
292
293   ''; #no errors
294 }
295
296 =item suspend
297
298 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
299 package, then suspends the package itself (sets the susp field to now).
300
301 If there is an error, returns the error, otherwise returns false.
302
303 =cut
304
305 sub suspend {
306   my $self = shift;
307   my $error ;
308
309   local $SIG{HUP} = 'IGNORE';
310   local $SIG{INT} = 'IGNORE';
311   local $SIG{QUIT} = 'IGNORE'; 
312   local $SIG{TERM} = 'IGNORE';
313   local $SIG{TSTP} = 'IGNORE';
314   local $SIG{PIPE} = 'IGNORE';
315
316   my $oldAutoCommit = $FS::UID::AutoCommit;
317   local $FS::UID::AutoCommit = 0;
318   my $dbh = dbh;
319
320   foreach my $cust_svc (
321     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
322   ) {
323     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
324
325     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
326       $dbh->rollback if $oldAutoCommit;
327       return "Illegal svcdb value in part_svc!";
328     };
329     my $svcdb = $1;
330     require "FS/$svcdb.pm";
331
332     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
333     if ($svc) {
334       $error = $svc->suspend;
335       if ( $error ) {
336         $dbh->rollback if $oldAutoCommit;
337         return $error;
338       }
339     }
340
341   }
342
343   unless ( $self->getfield('susp') ) {
344     my %hash = $self->hash;
345     $hash{'susp'} = time;
346     my $new = new FS::cust_pkg ( \%hash );
347     $error = $new->replace($self);
348     if ( $error ) {
349       $dbh->rollback if $oldAutoCommit;
350       return $error;
351     }
352   }
353
354   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
355
356   ''; #no errors
357 }
358
359 =item unsuspend
360
361 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
362 package, then unsuspends the package itself (clears the susp field).
363
364 If there is an error, returns the error, otherwise returns false.
365
366 =cut
367
368 sub unsuspend {
369   my $self = shift;
370   my($error);
371
372   local $SIG{HUP} = 'IGNORE';
373   local $SIG{INT} = 'IGNORE';
374   local $SIG{QUIT} = 'IGNORE'; 
375   local $SIG{TERM} = 'IGNORE';
376   local $SIG{TSTP} = 'IGNORE';
377   local $SIG{PIPE} = 'IGNORE';
378
379   my $oldAutoCommit = $FS::UID::AutoCommit;
380   local $FS::UID::AutoCommit = 0;
381   my $dbh = dbh;
382
383   foreach my $cust_svc (
384     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
385   ) {
386     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
387
388     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
389       $dbh->rollback if $oldAutoCommit;
390       return "Illegal svcdb value in part_svc!";
391     };
392     my $svcdb = $1;
393     require "FS/$svcdb.pm";
394
395     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
396     if ($svc) {
397       $error = $svc->unsuspend;
398       if ( $error ) {
399         $dbh->rollback if $oldAutoCommit;
400         return $error;
401       }
402     }
403
404   }
405
406   unless ( ! $self->getfield('susp') ) {
407     my %hash = $self->hash;
408     $hash{'susp'} = '';
409     my $new = new FS::cust_pkg ( \%hash );
410     $error = $new->replace($self);
411     if ( $error ) {
412       $dbh->rollback if $oldAutoCommit;
413       return $error;
414     }
415   }
416
417   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
418
419   ''; #no errors
420 }
421
422 =item last_bill
423
424 Returns the last bill date, or if there is no last bill date, the setup date.
425 Useful for billing metered services.
426
427 =cut
428
429 sub last_bill {
430   my $self = shift;
431   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
432                                                   'edate'  => $self->bill,  } );
433   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
434 }
435
436 =item part_pkg
437
438 Returns the definition for this billing item, as an FS::part_pkg object (see
439 L<FS::part_pkg>).
440
441 =cut
442
443 sub part_pkg {
444   my $self = shift;
445   #exists( $self->{'_pkgpart'} )
446   $self->{'_pkgpart'}
447     ? $self->{'_pkgpart'}
448     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
449 }
450
451 =item cust_svc
452
453 Returns the services for this package, as FS::cust_svc objects (see
454 L<FS::cust_svc>)
455
456 =cut
457
458 sub cust_svc {
459   my $self = shift;
460   if ( $self->{'_svcnum'} ) {
461     values %{ $self->{'_svcnum'}->cache };
462   } else {
463     qsearch ( 'cust_svc', { 'pkgnum' => $self->pkgnum } );
464   }
465 }
466
467 =item labels
468
469 Returns a list of lists, calling the label method for all services
470 (see L<FS::cust_svc>) of this billing item.
471
472 =cut
473
474 sub labels {
475   my $self = shift;
476   map { [ $_->label ] } $self->cust_svc;
477 }
478
479 =item cust_main
480
481 Returns the parent customer object (see L<FS::cust_main>).
482
483 =cut
484
485 sub cust_main {
486   my $self = shift;
487   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
488 }
489
490 =item seconds_since TIMESTAMP
491
492 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
493 package have been online since TIMESTAMP, according to the session monitor.
494
495 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
496 L<Time::Local> and L<Date::Parse> for conversion functions.
497
498 =cut
499
500 sub seconds_since {
501   my($self, $since) = @_;
502   my $seconds = 0;
503
504   foreach my $cust_svc (
505     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
506   ) {
507     $seconds += $cust_svc->seconds_since($since);
508   }
509
510   $seconds;
511
512 }
513
514 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
515
516 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
517 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
518 (exclusive).
519
520 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
521 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
522 functions.
523
524
525 =cut
526
527 sub seconds_since_sqlradacct {
528   my($self, $start, $end) = @_;
529
530   my $seconds = 0;
531
532   foreach my $cust_svc (
533     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
534   ) {
535     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
536   }
537
538   $seconds;
539
540 }
541
542 =back
543
544 =head1 SUBROUTINES
545
546 =over 4
547
548 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
549
550 CUSTNUM is a customer (see L<FS::cust_main>)
551
552 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
553 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
554 permitted.
555
556 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
557 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
558 new billing items.  An error is returned if this is not possible (see
559 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
560 parameter.
561
562 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
563 newly-created cust_pkg objects.
564
565 =cut
566
567 sub order {
568   my($custnum, $pkgparts, $remove_pkgnums, $return_cust_pkg) = @_;
569   $remove_pkgnums = [] unless defined($remove_pkgnums);
570
571   my $oldAutoCommit = $FS::UID::AutoCommit;
572   local $FS::UID::AutoCommit = 0;
573   my $dbh = dbh;
574
575   # generate %part_pkg
576   # $part_pkg{$pkgpart} is true iff $custnum may purchase $pkgpart
577   #
578   my($cust_main)=qsearchs('cust_main',{'custnum'=>$custnum});
579   my($agent)=qsearchs('agent',{'agentnum'=> $cust_main->agentnum });
580   my %part_pkg = %{ $agent->pkgpart_hashref };
581
582   my(%svcnum);
583   # generate %svcnum
584   # for those packages being removed:
585   #@{ $svcnum{$svcpart} } goes from a svcpart to a list of FS::cust_svc objects
586   my($pkgnum);
587   foreach $pkgnum ( @{$remove_pkgnums} ) {
588     foreach my $cust_svc (qsearch('cust_svc',{'pkgnum'=>$pkgnum})) {
589       push @{ $svcnum{$cust_svc->getfield('svcpart')} }, $cust_svc;
590     }
591   }
592   
593   my @cust_svc;
594   #generate @cust_svc
595   # for those packages the customer is purchasing:
596   # @{$pkgparts} is a list of said packages, by pkgpart
597   # @cust_svc is a corresponding list of lists of FS::Record objects
598   foreach my $pkgpart ( @{$pkgparts} ) {
599     unless ( $part_pkg{$pkgpart} ) {
600       $dbh->rollback if $oldAutoCommit;
601       return "Customer not permitted to purchase pkgpart $pkgpart!";
602     }
603     push @cust_svc, [
604       map {
605         ( $svcnum{$_} && @{ $svcnum{$_} } ) ? shift @{ $svcnum{$_} } : ();
606       } map { $_->svcpart }
607           qsearch('pkg_svc', { pkgpart  => $pkgpart,
608                                quantity => { op=>'>', value=>'0', } } )
609     ];
610   }
611
612   #special-case until this can be handled better
613   # move services to new svcparts - even if the svcparts don't match (svcdb
614   # needs to...)
615   # looks like they're moved in no particular order, ewwwwwwww
616   # and looks like just one of each svcpart can be moved... o well
617
618   #start with still-leftover services
619   #foreach my $svcpart ( grep { scalar(@{ $svcnum{$_} }) } keys %svcnum ) {
620   foreach my $svcpart ( keys %svcnum ) {
621     next unless @{ $svcnum{$svcpart} };
622
623     my $svcdb = $svcnum{$svcpart}->[0]->part_svc->svcdb;
624
625     #find an empty place to put one
626     my $i = 0;
627     foreach my $pkgpart ( @{$pkgparts} ) {
628       my @pkg_svc =
629         qsearch('pkg_svc', { pkgpart  => $pkgpart,
630                              quantity => { op=>'>', value=>'0', } } );
631       #my @pkg_svc =
632       #  grep { $_->quantity > 0 } qsearch('pkg_svc', { pkgpart=>$pkgpart } );
633       if ( ! @{$cust_svc[$i]} #find an empty place to put them with 
634            && grep { $svcdb eq $_->part_svc->svcdb } #with appropriate svcdb
635                 @pkg_svc
636       ) {
637         my $new_svcpart =
638           ( grep { $svcdb eq $_->part_svc->svcdb } @pkg_svc )[0]->svcpart; 
639         my $cust_svc = shift @{$svcnum{$svcpart}};
640         $cust_svc->svcpart($new_svcpart);
641         #warn "changing from $svcpart to $new_svcpart!!!\n";
642         $cust_svc[$i] = [ $cust_svc ];
643       }
644       $i++;
645     }
646
647   }
648   
649   #check for leftover services
650   foreach (keys %svcnum) {
651     next unless @{ $svcnum{$_} };
652     $dbh->rollback if $oldAutoCommit;
653     return "Leftover services, svcpart $_: svcnum ".
654            join(', ', map { $_->svcnum } @{ $svcnum{$_} } );
655   }
656
657   #no leftover services, let's make changes.
658  
659   local $SIG{HUP} = 'IGNORE';
660   local $SIG{INT} = 'IGNORE'; 
661   local $SIG{QUIT} = 'IGNORE';
662   local $SIG{TERM} = 'IGNORE';
663   local $SIG{TSTP} = 'IGNORE'; 
664   local $SIG{PIPE} = 'IGNORE'; 
665
666   #first cancel old packages
667   foreach my $pkgnum ( @{$remove_pkgnums} ) {
668     my($old) = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
669     unless ( $old ) {
670       $dbh->rollback if $oldAutoCommit;
671       return "Package $pkgnum not found to remove!";
672     }
673     my(%hash) = $old->hash;
674     $hash{'cancel'}=time;   
675     my($new) = new FS::cust_pkg ( \%hash );
676     my($error)=$new->replace($old);
677     if ( $error ) {
678       $dbh->rollback if $oldAutoCommit;
679       return "Couldn't update package $pkgnum: $error";
680     }
681   }
682
683   #now add new packages, changing cust_svc records if necessary
684   my $pkgpart;
685   while ($pkgpart=shift @{$pkgparts} ) {
686  
687     my $new = new FS::cust_pkg {
688                                  'custnum' => $custnum,
689                                  'pkgpart' => $pkgpart,
690                                };
691     my $error = $new->insert;
692     if ( $error ) {
693       $dbh->rollback if $oldAutoCommit;
694       return "Couldn't insert new cust_pkg record: $error";
695     }
696     push @{$return_cust_pkg}, $new if $return_cust_pkg;
697     my $pkgnum = $new->pkgnum;
698  
699     foreach my $cust_svc ( @{ shift @cust_svc } ) {
700       my(%hash) = $cust_svc->hash;
701       $hash{'pkgnum'}=$pkgnum;
702       my $new = new FS::cust_svc ( \%hash );
703
704       #avoid Record diffing missing changed svcpart field from above.
705       my $old = qsearchs('cust_svc', { 'svcnum' => $cust_svc->svcnum } );
706
707       my $error = $new->replace($old);
708       if ( $error ) {
709         $dbh->rollback if $oldAutoCommit;
710         return "Couldn't link old service to new package: $error";
711       }
712     }
713   }  
714
715   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
716
717   ''; #no errors
718 }
719
720 =back
721
722 =head1 VERSION
723
724 $Id: cust_pkg.pm,v 1.27 2002-10-17 14:16:17 ivan Exp $
725
726 =head1 BUGS
727
728 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
729
730 In sub order, the @pkgparts array (passed by reference) is clobbered.
731
732 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
733 method to pass dates to the recur_prog expression, it should do so.
734
735 FS::svc_acct, FS::svc_domain, FS::svc_www and FS::svc_forward are loaded via
736 'use' at compile time, rather than via 'require' in sub
737 { setup, suspend, unsuspend, cancel } because they use %FS::UID::callback to
738 load configuration values.  Probably need a subroutine which decides what to
739 do based on whether or not we've fetched the user yet, rather than a hash.
740 See FS::UID and the TODO.
741
742 Now that things are transactional should the check in the insert method be
743 moved to check ?
744
745 =head1 SEE ALSO
746
747 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
748 L<FS::pkg_svc>, schema.html from the base documentation
749
750 =cut
751
752 1;
753