add conditions for customer cancelled packages, RT#42043
[freeside.git] / FS / FS / cust_main / Packages.pm
1 package FS::cust_main::Packages;
2
3 use strict;
4 use vars qw( $DEBUG $me $skip_label_sort );
5 use List::Util qw( min );
6 use FS::UID qw( dbh );
7 use FS::Record qw( qsearch qsearchs );
8 use FS::cust_pkg;
9 use FS::cust_svc;
10 use FS::contact;       # for attach_pkgs
11 use FS::cust_location; #
12
13 $DEBUG = 0;
14 $me = '[FS::cust_main::Packages]';
15 $skip_label_sort = 0;
16
17 =head1 NAME
18
19 FS::cust_main::Packages - Packages mixin for cust_main
20
21 =head1 SYNOPSIS
22
23 =head1 DESCRIPTION
24
25 These methods are available on FS::cust_main objects;
26
27 =head1 METHODS
28
29 =over 4
30
31 =item order_pkg HASHREF | OPTION => VALUE ... 
32
33 Orders a single package.
34
35 Note that if the package definition has supplemental packages, those will
36 be ordered as well.
37
38 Options may be passed as a list of key/value pairs or as a hash reference.
39 Options are:
40
41 =over 4
42
43 =item cust_pkg
44
45 FS::cust_pkg object
46
47 =item cust_location
48
49 Optional FS::cust_location object.  If not specified, the customer's 
50 ship_location will be used.
51
52 =item svcs
53
54 Optional arryaref of FS::svc_* service objects.
55
56 =item depend_jobnum
57
58 If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
59 jobs will have a dependancy on the supplied job (they will not run until the
60 specific job completes).  This can be used to defer provisioning until some
61 action completes (such as running the customer's credit card successfully).
62
63 =item noexport
64
65 This option is option is deprecated but still works for now (use
66 I<depend_jobnum> instead for new code).  If I<noexport> is set true, no
67 provisioning jobs (exports) are scheduled.  (You can schedule them later with
68 the B<reexport> method for each cust_pkg object.  Using the B<reexport> method
69 on the cust_main object is not recommended, as existing services will also be
70 reexported.)
71
72 =item ticket_subject
73
74 Optional subject for a ticket created and attached to this customer
75
76 =item ticket_queue
77
78 Optional queue name for ticket additions
79
80 =back
81
82 =cut
83
84 sub order_pkg {
85   my $self = shift;
86   my $opt = ref($_[0]) ? shift : { @_ };
87
88   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
89
90   warn "$me order_pkg called with options ".
91        join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
92     if $DEBUG;
93
94   local $FS::svc_Common::noexport_hack = 1 if $opt->{'noexport'};
95
96   my $cust_pkg = $opt->{'cust_pkg'};
97   my $svcs     = $opt->{'svcs'} || [];
98
99   my %svc_options = ();
100   $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
101     if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
102
103   my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
104                           qw( ticket_subject ticket_queue allow_pkgpart );
105
106   local $SIG{HUP} = 'IGNORE';
107   local $SIG{INT} = 'IGNORE';
108   local $SIG{QUIT} = 'IGNORE';
109   local $SIG{TERM} = 'IGNORE';
110   local $SIG{TSTP} = 'IGNORE';
111   local $SIG{PIPE} = 'IGNORE';
112
113   my $oldAutoCommit = $FS::UID::AutoCommit;
114   local $FS::UID::AutoCommit = 0;
115   my $dbh = dbh;
116
117   if ( $opt->{'contactnum'} and $opt->{'contactnum'} != -1 ) {
118
119     $cust_pkg->contactnum($opt->{'contactnum'});
120
121   } elsif ( $opt->{'contact'} ) {
122
123     if ( ! $opt->{'contact'}->contactnum ) {
124       # not inserted yet
125       my $error = $opt->{'contact'}->insert;
126       if ( $error ) {
127         $dbh->rollback if $oldAutoCommit;
128         return "inserting contact (transaction rolled back): $error";
129       }
130     }
131     $cust_pkg->contactnum($opt->{'contact'}->contactnum);
132
133   #} else {
134   #
135   #  $cust_pkg->contactnum();
136
137   }
138
139   if ( $opt->{'locationnum'} and $opt->{'locationnum'} != -1 ) {
140
141     $cust_pkg->locationnum($opt->{'locationnum'});
142
143   } elsif ( $opt->{'cust_location'} ) {
144
145     my $error = $opt->{'cust_location'}->find_or_insert;
146     if ( $error ) {
147       $dbh->rollback if $oldAutoCommit;
148       return "inserting cust_location (transaction rolled back): $error";
149     }
150     $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
151
152   } else {
153
154     $cust_pkg->locationnum($self->ship_locationnum);
155
156   }
157
158   $cust_pkg->custnum( $self->custnum );
159
160   my $error = $cust_pkg->insert( %insert_params );
161   if ( $error ) {
162     $dbh->rollback if $oldAutoCommit;
163     return "inserting cust_pkg (transaction rolled back): $error";
164   }
165
166   foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
167     if ( $svc_something->svcnum ) {
168       my $old_cust_svc = $svc_something->cust_svc;
169       my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
170       $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
171       $error = $new_cust_svc->replace($old_cust_svc);
172     } else {
173       $svc_something->pkgnum( $cust_pkg->pkgnum );
174       if ( $svc_something->isa('FS::svc_acct') ) {
175         foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
176                        qw( seconds upbytes downbytes totalbytes )      ) {
177           $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
178           ${ $opt->{$_.'_ref'} } = 0;
179         }
180       }
181       $error = $svc_something->insert(%svc_options);
182     }
183     if ( $error ) {
184       $dbh->rollback if $oldAutoCommit;
185       return "inserting svc_ (transaction rolled back): $error";
186     }
187   }
188
189   # add supplemental packages, if any are needed
190   my $part_pkg = FS::part_pkg->by_key($cust_pkg->pkgpart);
191   foreach my $link ($part_pkg->supp_part_pkg_link) {
192     #warn "inserting supplemental package ".$link->dst_pkgpart;
193     my $pkg = FS::cust_pkg->new({
194         'pkgpart'       => $link->dst_pkgpart,
195         'pkglinknum'    => $link->pkglinknum,
196         'custnum'       => $self->custnum,
197         'main_pkgnum'   => $cust_pkg->pkgnum,
198         # try to prevent as many surprises as possible
199         'allow_pkgpart' => $opt->{'allow_pkgpart'},
200         map { $_ => $cust_pkg->$_() }
201           qw( pkgbatch
202               start_date order_date expire adjourn contract_end
203               refnum discountnum waive_setup
204             )
205     });
206     $error = $self->order_pkg('cust_pkg'    => $pkg,
207                               'locationnum' => $cust_pkg->locationnum);
208     if ( $error ) {
209       $dbh->rollback if $oldAutoCommit;
210       return "inserting supplemental package: $error";
211     }
212   }
213
214   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
215   ''; #no error
216
217 }
218
219 =item order_pkgs HASHREF [ , OPTION => VALUE ... ]
220
221 Like the insert method on an existing record, this method orders multiple
222 packages and included services atomicaly.  Pass a Tie::RefHash data structure
223 to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
224 There should be a better explanation of this, but until then, here's an
225 example:
226
227   use Tie::RefHash;
228   tie %hash, 'Tie::RefHash'; #this part is important
229   %hash = (
230     $cust_pkg => [ $svc_acct ],
231     ...
232   );
233   $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
234
235 Services can be new, in which case they are inserted, or existing unaudited
236 services, in which case they are linked to the newly-created package.
237
238 Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
239 I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
240
241 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
242 on the supplied jobnum (they will not run until the specific job completes).
243 This can be used to defer provisioning until some action completes (such
244 as running the customer's credit card successfully).
245
246 The I<noexport> option is deprecated but still works for now (use
247 I<depend_jobnum> instead for new code).  If I<noexport> is set true, no
248 provisioning jobs (exports) are scheduled.  (You can schedule them later with
249 the B<reexport> method for each cust_pkg object.  Using the B<reexport> method
250 on the cust_main object is not recommended, as existing services will also be
251 reexported.)
252
253 If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
254 provided, the scalars (provided by references) will be incremented by the
255 values of the prepaid card.`
256
257 =cut
258
259 sub order_pkgs {
260   my $self = shift;
261   my $cust_pkgs = shift;
262   my %options = @_;
263
264   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
265
266   warn "$me order_pkgs called with options ".
267        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
268     if $DEBUG;
269
270   local $SIG{HUP} = 'IGNORE';
271   local $SIG{INT} = 'IGNORE';
272   local $SIG{QUIT} = 'IGNORE';
273   local $SIG{TERM} = 'IGNORE';
274   local $SIG{TSTP} = 'IGNORE';
275   local $SIG{PIPE} = 'IGNORE';
276
277   my $oldAutoCommit = $FS::UID::AutoCommit;
278   local $FS::UID::AutoCommit = 0;
279   my $dbh = dbh;
280
281   local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
282
283   foreach my $cust_pkg ( keys %$cust_pkgs ) {
284
285     my $error = $self->order_pkg(
286       'cust_pkg'     => $cust_pkg,
287       'svcs'         => $cust_pkgs->{$cust_pkg},
288       map { $_ => $options{$_} }
289         qw( seconds_ref upbytes_ref downbytes_ref totalbytes_ref depend_jobnum )
290     );
291     if ( $error ) {
292       $dbh->rollback if $oldAutoCommit;
293       return $error;
294     }
295
296   }
297
298   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
299   ''; #no error
300 }
301
302 =item attach_pkgs 
303
304 Merges this customer's package's into the target customer and then cancels them.
305
306 =cut
307
308 sub attach_pkgs {
309   my( $self, $new_custnum ) = @_;
310
311   #mostly false laziness w/ merge
312
313   return "Can't attach packages to self" if $self->custnum == $new_custnum;
314
315   my $new_cust_main = qsearchs( 'cust_main', { 'custnum' => $new_custnum } )
316     or return "Invalid new customer number: $new_custnum";
317
318   return 'Access denied: "Merge customer across agents" access right required to merge into a customer of a different agent'
319     if $self->agentnum != $new_cust_main->agentnum 
320     && ! $FS::CurrentUser::CurrentUser->access_right('Merge customer across agents');
321
322   local $SIG{HUP} = 'IGNORE';
323   local $SIG{INT} = 'IGNORE';
324   local $SIG{QUIT} = 'IGNORE';
325   local $SIG{TERM} = 'IGNORE';
326   local $SIG{TSTP} = 'IGNORE';
327   local $SIG{PIPE} = 'IGNORE';
328
329   my $oldAutoCommit = $FS::UID::AutoCommit;
330   local $FS::UID::AutoCommit = 0;
331   my $dbh = dbh;
332
333   if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
334      $dbh->rollback if $oldAutoCommit;
335      return "Can't merge a master agent customer";
336   }
337
338   #use FS::access_user
339   if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
340      $dbh->rollback if $oldAutoCommit;
341      return "Can't merge a master employee customer";
342   }
343
344   if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
345                                      'status'  => { op=>'!=', value=>'done' },
346                                    }
347               )
348   ) {
349      $dbh->rollback if $oldAutoCommit;
350      return "Can't merge a customer with pending payments";
351   }
352
353   #end of false laziness
354
355   #pull in contact
356
357   my %contact_hash = ( 'first'    => $self->first,
358                        'last'     => $self->get('last'),
359                        'custnum'  => $new_custnum,
360                        'disabled' => '',
361                      );
362
363   my $contact = qsearchs(  'contact', \%contact_hash)
364                  || new FS::contact   \%contact_hash;
365   unless ( $contact->contactnum ) {
366     my $error = $contact->insert;
367     if ( $error ) {
368       $dbh->rollback if $oldAutoCommit;
369       return $error;
370     }
371   }
372
373   foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
374
375     my $cust_location = $cust_pkg->cust_location || $self->ship_location;
376     my %loc_hash = $cust_location->hash;
377     $loc_hash{'locationnum'} = '';
378     $loc_hash{'custnum'}     = $new_custnum;
379     $loc_hash{'disabled'}    = '';
380     my $new_cust_location = qsearchs(  'cust_location', \%loc_hash)
381                              || new FS::cust_location   \%loc_hash;
382
383     my $pkg_or_error = $cust_pkg->change( {
384       'keep_dates'    => 1,
385       'cust_main'     => $new_cust_main,
386       'contactnum'    => $contact->contactnum,
387       'cust_location' => $new_cust_location,
388     } );
389
390     my $error = ref($pkg_or_error) ? '' : $pkg_or_error;
391
392     if ( $error ) {
393       $dbh->rollback if $oldAutoCommit;
394       return $error;
395     }
396
397   }
398
399   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
400   ''; #no error
401
402 }
403
404 =item all_pkgs [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
405
406 Returns all packages (see L<FS::cust_pkg>) for this customer.
407
408 =cut
409
410 sub all_pkgs {
411   my $self = shift;
412   my $extra_qsearch = ref($_[0]) ? shift : { @_ };
413
414   return $self->num_pkgs unless wantarray || keys %$extra_qsearch;
415
416   my @cust_pkg = ();
417   if ( $self->{'_pkgnum'} && ! keys %$extra_qsearch ) {
418     @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
419   } else {
420     @cust_pkg = $self->_cust_pkg($extra_qsearch);
421   }
422
423   local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort};
424   map { $_ } sort sort_packages @cust_pkg;
425
426 }
427
428 =item cust_pkg
429
430 Synonym for B<all_pkgs>.
431
432 =cut
433
434 sub cust_pkg {
435   shift->all_pkgs(@_);
436 }
437
438 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
439
440 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
441
442 =cut
443
444 sub ncancelled_pkgs {
445   my $self = shift;
446   my $extra_qsearch = ref($_[0]) ? shift : {};
447
448   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
449
450   return $self->num_ncancelled_pkgs unless wantarray;
451
452   my @cust_pkg = ();
453   if ( $self->{'_pkgnum'} ) {
454
455     warn "$me ncancelled_pkgs: returning cached objects"
456       if $DEBUG > 1;
457
458     @cust_pkg = grep { ! $_->getfield('cancel') }
459                 values %{ $self->{'_pkgnum'}->cache };
460
461   } else {
462
463     warn "$me ncancelled_pkgs: searching for packages with custnum ".
464          $self->custnum. "\n"
465       if $DEBUG > 1;
466
467     $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
468
469     @cust_pkg = $self->_cust_pkg($extra_qsearch);
470
471   }
472
473   local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort};
474   sort sort_packages @cust_pkg;
475
476 }
477
478 =item cancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
479
480 Returns all cancelled packages (see L<FS::cust_pkg>) for this customer.
481
482 =cut
483
484 sub cancelled_pkgs {
485   my $self = shift;
486   my $extra_qsearch = ref($_[0]) ? shift : { @_ };
487
488   return $self->num_cancelled_pkgs($extra_qsearch) unless wantarray;
489
490   $extra_qsearch->{'extra_sql'} .=
491     ' AND cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel > 0 ';
492
493   local($skip_label_sort) = 1 if $extra_qsearch->{skip_label_sort};
494
495   sort sort_packages $self->_cust_pkg($extra_qsearch);
496 }
497
498 sub _cust_pkg {
499   my $self = shift;
500   my $extra_qsearch = ref($_[0]) ? shift : {};
501
502   $extra_qsearch->{'select'} ||= '*';
503   $extra_qsearch->{'select'} .=
504    ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
505      AS _num_cust_svc';
506
507   map {
508         $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
509         $_;
510       }
511   qsearch({
512     %$extra_qsearch,
513     'table'   => 'cust_pkg',
514     'hashref' => { 'custnum' => $self->custnum },
515   });
516
517 }
518
519 # This should be generalized to use config options to determine order.
520 sub sort_packages {
521   
522   my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
523   return $locationsort if $locationsort;
524
525   if ( $a->get('cancel') xor $b->get('cancel') ) {
526     return -1 if $b->get('cancel');
527     return  1 if $a->get('cancel');
528     #shouldn't get here...
529     return 0;
530   } else {
531     my $a_num_cust_svc = $a->num_cust_svc;
532     my $b_num_cust_svc = $b->num_cust_svc;
533     return 0  if !$a_num_cust_svc && !$b_num_cust_svc;
534     return -1 if  $a_num_cust_svc && !$b_num_cust_svc;
535     return 1  if !$a_num_cust_svc &&  $b_num_cust_svc;
536     return 0 if $skip_label_sort
537              || $a_num_cust_svc + $b_num_cust_svc > 20; #for perf, just give up
538     my @a_cust_svc = $a->cust_svc_unsorted;
539     my @b_cust_svc = $b->cust_svc_unsorted;
540     return 0  if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
541     return -1 if  scalar(@a_cust_svc) && !scalar(@b_cust_svc);
542     return 1  if !scalar(@a_cust_svc) &&  scalar(@b_cust_svc);
543     $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
544   }
545
546 }
547
548 =item suspended_pkgs
549
550 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
551
552 =cut
553
554 sub suspended_pkgs {
555   my $self = shift;
556   return $self->num_suspended_pkgs unless wantarray;
557   grep { $_->susp } $self->ncancelled_pkgs;
558 }
559
560 ### This appears to be unused, will be going away
561 #
562 #=item unflagged_suspended_pkgs
563 #
564 #Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
565 #customer (thouse packages without the `manual_flag' set).
566 #
567 #=cut
568
569 sub unflagged_suspended_pkgs {
570   my $self = shift;
571   return $self->suspended_pkgs
572     unless dbdef->table('cust_pkg')->column('manual_flag');
573   grep { ! $_->manual_flag } $self->suspended_pkgs;
574 }
575
576 =item unsuspended_pkgs
577
578 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
579 this customer.
580
581 =cut
582
583 sub unsuspended_pkgs {
584   my $self = shift;
585   return $self->num_unsuspended_pkgs unless wantarray;
586   grep { ! $_->susp } $self->ncancelled_pkgs;
587 }
588
589 =item active_pkgs
590
591 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
592 this customer that are active (recurring).
593
594 =cut
595
596 sub active_pkgs {
597   my $self = shift; 
598   grep { my $part_pkg = $_->part_pkg;
599          $part_pkg->freq ne '' && $part_pkg->freq ne '0';
600        }
601        $self->unsuspended_pkgs;
602 }
603
604 =item ncancelled_active_pkgs
605
606 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer that
607 are active (recurring).
608
609 =cut
610
611 sub ncancelled_active_pkgs {
612   my $self = shift; 
613   grep { my $part_pkg = $_->part_pkg;
614          $part_pkg->freq ne '' && $part_pkg->freq ne '0';
615        }
616        $self->ncancelled_pkgs;
617 }
618
619 =item billing_pkgs
620
621 Returns active packages, and also any suspended packages which are set to
622 continue billing while suspended.
623
624 =cut
625
626 sub billing_pkgs {
627   my $self = shift;
628   grep { my $part_pkg = $_->part_pkg;
629          $part_pkg->freq ne '' && $part_pkg->freq ne '0'
630            && ( ! $_->susp || $_->option('suspend_bill',1)
631                            || ( $part_pkg->option('suspend_bill', 1)
632                                   && ! $_->option('no_suspend_bill',1)
633                               )
634               );
635        }
636        $self->ncancelled_pkgs;
637 }
638
639 =item next_bill_date
640
641 Returns the next date this customer will be billed, as a UNIX timestamp, or
642 undef if no billing package has a next bill date.
643
644 =cut
645
646 sub next_bill_date {
647   my $self = shift;
648   min( map $_->get('bill'), grep $_->get('bill'), $self->billing_pkgs );
649 }
650
651 =item num_cancelled_pkgs
652
653 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
654 customer.
655
656 =cut
657
658 sub num_cancelled_pkgs {
659   shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
660 }
661
662 sub num_ncancelled_pkgs {
663   shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
664 }
665
666 sub num_suspended_pkgs {
667   shift->num_pkgs("     ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
668                     AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0   ");
669 }
670
671 sub num_unsuspended_pkgs {
672   shift->num_pkgs("     ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
673                     AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 ) ");
674 }
675
676 sub num_pkgs {
677   my( $self ) = shift;
678   my $sql = scalar(@_) ? shift : '';
679   $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
680   my $sth = dbh->prepare(
681     "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
682   ) or die dbh->errstr;
683   $sth->execute($self->custnum) or die $sth->errstr;
684   $sth->fetchrow_arrayref->[0];
685 }
686
687 =back
688
689 =head1 BUGS
690
691 =head1 SEE ALSO
692
693 L<FS::cust_main>, L<FS::cust_pkg>
694
695 =cut
696
697 1;
698