show credit balance on invoices, #11564
[freeside.git] / FS / FS / cust_svc.pm
1 package FS::cust_svc;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me $ignore_quantity );
5 use Carp;
6 #use Scalar::Util qw( blessed );
7 use FS::Conf;
8 use FS::Record qw( qsearch qsearchs dbh str2time_sql );
9 use FS::cust_pkg;
10 use FS::part_pkg;
11 use FS::part_svc;
12 use FS::pkg_svc;
13 use FS::domain_record;
14 use FS::part_export;
15 use FS::cdr;
16
17 #most FS::svc_ classes are autoloaded in svc_x emthod
18 use FS::svc_acct;  #this one is used in the cache stuff
19
20 @ISA = qw( FS::cust_main_Mixin FS::option_Common ); #FS::Record );
21
22 $DEBUG = 0;
23 $me = '[cust_svc]';
24
25 $ignore_quantity = 0;
26
27 sub _cache {
28   my $self = shift;
29   my ( $hashref, $cache ) = @_;
30   if ( $hashref->{'username'} ) {
31     $self->{'_svc_acct'} = FS::svc_acct->new($hashref, '');
32   }
33   if ( $hashref->{'svc'} ) {
34     $self->{'_svcpart'} = FS::part_svc->new($hashref);
35   }
36 }
37
38 =head1 NAME
39
40 FS::cust_svc - Object method for cust_svc objects
41
42 =head1 SYNOPSIS
43
44   use FS::cust_svc;
45
46   $record = new FS::cust_svc \%hash
47   $record = new FS::cust_svc { 'column' => 'value' };
48
49   $error = $record->insert;
50
51   $error = $new_record->replace($old_record);
52
53   $error = $record->delete;
54
55   $error = $record->check;
56
57   ($label, $value) = $record->label;
58
59 =head1 DESCRIPTION
60
61 An FS::cust_svc represents a service.  FS::cust_svc inherits from FS::Record.
62 The following fields are currently supported:
63
64 =over 4
65
66 =item svcnum - primary key (assigned automatically for new services)
67
68 =item pkgnum - Package (see L<FS::cust_pkg>)
69
70 =item svcpart - Service definition (see L<FS::part_svc>)
71
72 =item overlimit - date the service exceeded its usage limit
73
74 =back
75
76 =head1 METHODS
77
78 =over 4
79
80 =item new HASHREF
81
82 Creates a new service.  To add the refund to the database, see L<"insert">.
83 Services are normally created by creating FS::svc_ objects (see
84 L<FS::svc_acct>, L<FS::svc_domain>, and L<FS::svc_forward>, among others).
85
86 =cut
87
88 sub table { 'cust_svc'; }
89
90 =item insert
91
92 Adds this service to the database.  If there is an error, returns the error,
93 otherwise returns false.
94
95 =item delete
96
97 Deletes this service from the database.  If there is an error, returns the
98 error, otherwise returns false.  Note that this only removes the cust_svc
99 record - you should probably use the B<cancel> method instead.
100
101 =item cancel
102
103 Cancels the relevant service by calling the B<cancel> method of the associated
104 FS::svc_XXX object (i.e. an FS::svc_acct object or FS::svc_domain object),
105 deleting the FS::svc_XXX record and then deleting this record.
106
107 If there is an error, returns the error, otherwise returns false.
108
109 =cut
110
111 sub cancel {
112   my($self,%opt) = @_;
113
114   local $SIG{HUP} = 'IGNORE';
115   local $SIG{INT} = 'IGNORE';
116   local $SIG{QUIT} = 'IGNORE'; 
117   local $SIG{TERM} = 'IGNORE';
118   local $SIG{TSTP} = 'IGNORE';
119   local $SIG{PIPE} = 'IGNORE';
120
121   my $oldAutoCommit = $FS::UID::AutoCommit;
122   local $FS::UID::AutoCommit = 0;
123   my $dbh = dbh;
124
125   my $part_svc = $self->part_svc;
126
127   $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
128     $dbh->rollback if $oldAutoCommit;
129     return "Illegal svcdb value in part_svc!";
130   };
131   my $svcdb = $1;
132   require "FS/$svcdb.pm";
133
134   my $svc = $self->svc_x;
135   if ($svc) {
136     if ( %opt && $opt{'date'} ) {
137         my $error = $svc->expire($opt{'date'});
138         if ( $error ) {
139           $dbh->rollback if $oldAutoCommit;
140           return "Error expiring service: $error";
141         }
142     } else {
143         my $error = $svc->cancel;
144         if ( $error ) {
145           $dbh->rollback if $oldAutoCommit;
146           return "Error canceling service: $error";
147         }
148         $error = $svc->delete; #this deletes this cust_svc record as well
149         if ( $error ) {
150           $dbh->rollback if $oldAutoCommit;
151           return "Error deleting service: $error";
152         }
153     }
154
155   } elsif ( !%opt ) {
156
157     #huh?
158     warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
159          "; deleting cust_svc only\n"; 
160
161     my $error = $self->delete;
162     if ( $error ) {
163       $dbh->rollback if $oldAutoCommit;
164       return "Error deleting cust_svc: $error";
165     }
166
167   }
168
169   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
170
171   ''; #no errors
172
173 }
174
175 =item overlimit [ ACTION ]
176
177 Retrieves or sets the overlimit date.  If ACTION is absent, return
178 the present value of overlimit.  If ACTION is present, it can
179 have the value 'suspend' or 'unsuspend'.  In the case of 'suspend' overlimit
180 is set to the current time if it is not already set.  The 'unsuspend' value
181 causes the time to be cleared.  
182
183 If there is an error on setting, returns the error, otherwise returns false.
184
185 =cut
186
187 sub overlimit {
188   my $self = shift;
189   my $action = shift or return $self->getfield('overlimit');
190
191   local $SIG{HUP} = 'IGNORE';
192   local $SIG{INT} = 'IGNORE';
193   local $SIG{QUIT} = 'IGNORE'; 
194   local $SIG{TERM} = 'IGNORE';
195   local $SIG{TSTP} = 'IGNORE';
196   local $SIG{PIPE} = 'IGNORE';
197
198   my $oldAutoCommit = $FS::UID::AutoCommit;
199   local $FS::UID::AutoCommit = 0;
200   my $dbh = dbh;
201
202   if ( $action eq 'suspend' ) {
203     $self->setfield('overlimit', time) unless $self->getfield('overlimit');
204   }elsif ( $action eq 'unsuspend' ) {
205     $self->setfield('overlimit', '');
206   }else{
207     die "unexpected action value: $action";
208   }
209
210   local $ignore_quantity = 1;
211   my $error = $self->replace;
212   if ( $error ) {
213     $dbh->rollback if $oldAutoCommit;
214     return "Error setting overlimit: $error";
215   }
216
217   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
218
219   ''; #no errors
220
221 }
222
223 =item replace OLD_RECORD
224
225 Replaces the OLD_RECORD with this one in the database.  If there is an error,
226 returns the error, otherwise returns false.
227
228 =cut
229
230 sub replace {
231 #  my $new = shift;
232 #
233 #  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
234 #              ? shift
235 #              : $new->replace_old;
236   my ( $new, $old ) = ( shift, shift );
237   $old = $new->replace_old unless defined($old);
238
239   local $SIG{HUP} = 'IGNORE';
240   local $SIG{INT} = 'IGNORE';
241   local $SIG{QUIT} = 'IGNORE';
242   local $SIG{TERM} = 'IGNORE';
243   local $SIG{TSTP} = 'IGNORE';
244   local $SIG{PIPE} = 'IGNORE';
245
246   my $oldAutoCommit = $FS::UID::AutoCommit;
247   local $FS::UID::AutoCommit = 0;
248   my $dbh = dbh;
249
250   if ( $new->svcpart != $old->svcpart ) {
251     my $svc_x = $new->svc_x;
252     my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
253     local($FS::Record::nowarn_identical) = 1;
254     my $error = $new_svc_x->replace($svc_x);
255     if ( $error ) {
256       $dbh->rollback if $oldAutoCommit;
257       return $error if $error;
258     }
259   }
260
261 #  #trigger a re-export on pkgnum changes?
262 #  # (of prepaid packages), for Expiration RADIUS attribute
263 #  if ( $new->pkgnum != $old->pkgnum && $new->cust_pkg->part_pkg->is_prepaid ) {
264 #    my $svc_x = $new->svc_x;
265 #    local($FS::Record::nowarn_identical) = 1;
266 #    my $error = $svc_x->export('replace');
267 #    if ( $error ) {
268 #      $dbh->rollback if $oldAutoCommit;
269 #      return $error if $error;
270 #    }
271 #  }
272
273   #my $error = $new->SUPER::replace($old, @_);
274   my $error = $new->SUPER::replace($old);
275   if ( $error ) {
276     $dbh->rollback if $oldAutoCommit;
277     return $error if $error;
278   }
279
280   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
281   ''; #no error
282
283 }
284
285 =item check
286
287 Checks all fields to make sure this is a valid service.  If there is an error,
288 returns the error, otherwise returns false.  Called by the insert and
289 replace methods.
290
291 =cut
292
293 sub check {
294   my $self = shift;
295
296   my $error =
297     $self->ut_numbern('svcnum')
298     || $self->ut_numbern('pkgnum')
299     || $self->ut_number('svcpart')
300     || $self->ut_numbern('overlimit')
301   ;
302   return $error if $error;
303
304   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
305   return "Unknown svcpart" unless $part_svc;
306
307   if ( $self->pkgnum ) {
308     my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
309     return "Unknown pkgnum" unless $cust_pkg;
310     ($part_svc) = grep { $_->svcpart == $self->svcpart } $cust_pkg->part_svc;
311
312     return "Already ". $part_svc->get('num_cust_svc'). " ". $part_svc->svc.
313            " services for pkgnum ". $self->pkgnum
314       if $part_svc->get('num_avail') == 0 and !$ignore_quantity;
315   }
316
317   $self->SUPER::check;
318 }
319
320 =item part_svc
321
322 Returns the definition for this service, as a FS::part_svc object (see
323 L<FS::part_svc>).
324
325 =cut
326
327 sub part_svc {
328   my $self = shift;
329   $self->{'_svcpart'}
330     ? $self->{'_svcpart'}
331     : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
332 }
333
334 =item cust_pkg
335
336 Returns the package this service belongs to, as a FS::cust_pkg object (see
337 L<FS::cust_pkg>).
338
339 =cut
340
341 sub cust_pkg {
342   my $self = shift;
343   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
344 }
345
346 =item pkg_svc
347
348 Returns the pkg_svc record for for this service, if applicable.
349
350 =cut
351
352 sub pkg_svc {
353   my $self = shift;
354   my $cust_pkg = $self->cust_pkg;
355   return undef unless $cust_pkg;
356
357   qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
358                          'pkgpart' => $cust_pkg->pkgpart,
359                        }
360           );
361 }
362
363 =item date_inserted
364
365 Returns the date this service was inserted.
366
367 =cut
368
369 sub date_inserted {
370   my $self = shift;
371   $self->h_date('insert');
372 }
373
374 =item label
375
376 Returns a list consisting of:
377 - The name of this service (from part_svc)
378 - A meaningful identifier (username, domain, or mail alias)
379 - The table name (i.e. svc_domain) for this service
380 - svcnum
381
382 Usage example:
383
384   my($label, $value, $svcdb) = $cust_svc->label;
385
386 =item label_long
387
388 Like the B<label> method, except the second item in the list ("meaningful
389 identifier") may be longer - typically, a full name is included.
390
391 =cut
392
393 sub label      { shift->_label('svc_label',      @_); }
394 sub label_long { shift->_label('svc_label_long', @_); }
395
396 sub _label {
397   my $self = shift;
398   my $method = shift;
399   my $svc_x = $self->svc_x
400     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
401
402   $self->$method($svc_x);
403 }
404
405 sub svc_label      { shift->_svc_label('label',      @_); }
406 sub svc_label_long { shift->_svc_label('label_long', @_); }
407
408 sub _svc_label {
409   my( $self, $method, $svc_x ) = ( shift, shift, shift );
410
411   (
412     $self->part_svc->svc,
413     $svc_x->$method(@_),
414     $self->part_svc->svcdb,
415     $self->svcnum
416   );
417
418 }
419
420 =item export_links
421
422 Returns a listref of html elements associated with this service's exports.
423
424 =cut
425
426 sub export_links {
427   my $self = shift;
428   my $svc_x = $self->svc_x
429     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
430
431   $svc_x->export_links;
432 }
433
434 =item export_getsettings
435
436 Returns two hashrefs of settings associated with this service's exports.
437
438 =cut
439
440 sub export_getsettings {
441   my $self = shift;
442   my $svc_x = $self->svc_x
443     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
444
445   $svc_x->export_getsettings;
446 }
447
448
449 =item svc_x
450
451 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
452 FS::svc_domain object, etc.)
453
454 =cut
455
456 sub svc_x {
457   my $self = shift;
458   my $svcdb = $self->part_svc->svcdb;
459   if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
460     $self->{'_svc_acct'};
461   } else {
462     require "FS/$svcdb.pm";
463     warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
464          ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
465       if $DEBUG;
466     qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
467   }
468 }
469
470 =item seconds_since TIMESTAMP
471
472 See L<FS::svc_acct/seconds_since>.  Equivalent to
473 $cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
474 where B<svcdb> is not "svc_acct".
475
476 =cut
477
478 #internal session db deprecated (or at least on hold)
479 sub seconds_since { 'internal session db deprecated'; };
480 ##note: implementation here, POD in FS::svc_acct
481 #sub seconds_since {
482 #  my($self, $since) = @_;
483 #  my $dbh = dbh;
484 #  my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
485 #                              WHERE svcnum = ?
486 #                                AND login >= ?
487 #                                AND logout IS NOT NULL'
488 #  ) or die $dbh->errstr;
489 #  $sth->execute($self->svcnum, $since) or die $sth->errstr;
490 #  $sth->fetchrow_arrayref->[0];
491 #}
492
493 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
494
495 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
496 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
497 for records where B<svcdb> is not "svc_acct".
498
499 =cut
500
501 #note: implementation here, POD in FS::svc_acct
502 sub seconds_since_sqlradacct {
503   my($self, $start, $end) = @_;
504
505   my $mes = "$me seconds_since_sqlradacct:";
506
507   my $svc_x = $self->svc_x;
508
509   my @part_export = $self->part_svc->part_export_usage;
510   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
511       " service definition"
512     unless @part_export;
513     #or return undef;
514
515   my $seconds = 0;
516   foreach my $part_export ( @part_export ) {
517
518     next if $part_export->option('ignore_accounting');
519
520     warn "$mes connecting to sqlradius database\n"
521       if $DEBUG;
522
523     my $dbh = DBI->connect( map { $part_export->option($_) }
524                             qw(datasrc username password)    )
525       or die "can't connect to sqlradius database: ". $DBI::errstr;
526
527     warn "$mes connected to sqlradius database\n"
528       if $DEBUG;
529
530     #select a unix time conversion function based on database type
531     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
532     
533     my $username = $part_export->export_username($svc_x);
534
535     my $query;
536
537     warn "$mes finding closed sessions completely within the given range\n"
538       if $DEBUG;
539   
540     my $realm = '';
541     my $realmparam = '';
542     if ($part_export->option('process_single_realm')) {
543       $realm = 'AND Realm = ?';
544       $realmparam = $part_export->option('realm');
545     }
546
547     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
548                                FROM radacct
549                                WHERE UserName = ?
550                                  $realm
551                                  AND $str2time AcctStartTime) >= ?
552                                  AND $str2time AcctStopTime ) <  ?
553                                  AND $str2time AcctStopTime ) > 0
554                                  AND AcctStopTime IS NOT NULL"
555     ) or die $dbh->errstr;
556     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
557       or die $sth->errstr;
558     my $regular = $sth->fetchrow_arrayref->[0];
559   
560     warn "$mes finding open sessions which start in the range\n"
561       if $DEBUG;
562
563     # count session start->range end
564     $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
565                 FROM radacct
566                 WHERE UserName = ?
567                   $realm
568                   AND $str2time AcctStartTime ) >= ?
569                   AND $str2time AcctStartTime ) <  ?
570                   AND ( ? - $str2time AcctStartTime ) ) < 86400
571                   AND (    $str2time AcctStopTime ) = 0
572                                     OR AcctStopTime IS NULL )";
573     $sth = $dbh->prepare($query) or die $dbh->errstr;
574     $sth->execute( $end,
575                    $username,
576                    ($realm ? $realmparam : ()),
577                    $start,
578                    $end,
579                    $end )
580       or die $sth->errstr. " executing query $query";
581     my $start_during = $sth->fetchrow_arrayref->[0];
582   
583     warn "$mes finding closed sessions which start before the range but stop during\n"
584       if $DEBUG;
585
586     #count range start->session end
587     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
588                             FROM radacct
589                             WHERE UserName = ?
590                               $realm
591                               AND $str2time AcctStartTime ) < ?
592                               AND $str2time AcctStopTime  ) >= ?
593                               AND $str2time AcctStopTime  ) <  ?
594                               AND $str2time AcctStopTime ) > 0
595                               AND AcctStopTime IS NOT NULL"
596     ) or die $dbh->errstr;
597     $sth->execute( $start,
598                    $username,
599                    ($realm ? $realmparam : ()),
600                    $start,
601                    $start,
602                    $end )
603       or die $sth->errstr;
604     my $end_during = $sth->fetchrow_arrayref->[0];
605   
606     warn "$mes finding closed sessions which start before the range but stop after\n"
607       if $DEBUG;
608
609     # count range start->range end
610     # don't count open sessions anymore (probably missing stop record)
611     $sth = $dbh->prepare("SELECT COUNT(*)
612                             FROM radacct
613                             WHERE UserName = ?
614                               $realm
615                               AND $str2time AcctStartTime ) < ?
616                               AND ( $str2time AcctStopTime ) >= ?
617                                                                   )"
618                               #      OR AcctStopTime =  0
619                               #      OR AcctStopTime IS NULL       )"
620     ) or die $dbh->errstr;
621     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
622       or die $sth->errstr;
623     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
624
625     $seconds += $regular + $end_during + $start_during + $entire_range;
626
627     warn "$mes done finding sessions\n"
628       if $DEBUG;
629
630   }
631
632   $seconds;
633
634 }
635
636 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
637
638 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
639 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
640 for records where B<svcdb> is not "svc_acct".
641
642 =cut
643
644 #note: implementation here, POD in FS::svc_acct
645 #(false laziness w/seconds_since_sqlradacct above)
646 sub attribute_since_sqlradacct {
647   my($self, $start, $end, $attrib) = @_;
648
649   my $mes = "$me attribute_since_sqlradacct:";
650
651   my $svc_x = $self->svc_x;
652
653   my @part_export = $self->part_svc->part_export_usage;
654   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
655       " service definition"
656     unless @part_export;
657     #or return undef;
658
659   my $sum = 0;
660
661   foreach my $part_export ( @part_export ) {
662
663     next if $part_export->option('ignore_accounting');
664
665     warn "$mes connecting to sqlradius database\n"
666       if $DEBUG;
667
668     my $dbh = DBI->connect( map { $part_export->option($_) }
669                             qw(datasrc username password)    )
670       or die "can't connect to sqlradius database: ". $DBI::errstr;
671
672     warn "$mes connected to sqlradius database\n"
673       if $DEBUG;
674
675     #select a unix time conversion function based on database type
676     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
677
678     my $username = $part_export->export_username($svc_x);
679
680     warn "$mes SUMing $attrib sessions\n"
681       if $DEBUG;
682
683     my $realm = '';
684     my $realmparam = '';
685     if ($part_export->option('process_single_realm')) {
686       $realm = 'AND Realm = ?';
687       $realmparam = $part_export->option('realm');
688     }
689
690     my $sth = $dbh->prepare("SELECT SUM($attrib)
691                                FROM radacct
692                                WHERE UserName = ?
693                                  $realm
694                                  AND $str2time AcctStopTime ) >= ?
695                                  AND $str2time AcctStopTime ) <  ?
696                                  AND AcctStopTime IS NOT NULL"
697     ) or die $dbh->errstr;
698     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
699       or die $sth->errstr;
700
701     my $row = $sth->fetchrow_arrayref;
702     $sum += $row->[0] if defined($row->[0]);
703
704     warn "$mes done SUMing sessions\n"
705       if $DEBUG;
706
707   }
708
709   $sum;
710
711 }
712
713 =item get_session_history TIMESTAMP_START TIMESTAMP_END
714
715 See L<FS::svc_acct/get_session_history>.  Equivalent to
716 $cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
717 records where B<svcdb> is not "svc_acct".
718
719 =cut
720
721 sub get_session_history {
722   my($self, $start, $end, $attrib) = @_;
723
724   #$attrib ???
725
726   my @part_export = $self->part_svc->part_export_usage;
727   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
728       " service definition"
729     unless @part_export;
730     #or return undef;
731                      
732   my @sessions = ();
733
734   foreach my $part_export ( @part_export ) {
735     push @sessions,
736       @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
737   }
738
739   @sessions;
740
741 }
742
743 =back
744
745 =head1 BUGS
746
747 Behaviour of changing the svcpart of cust_svc records is undefined and should
748 possibly be prohibited, and pkg_svc records are not checked.
749
750 pkg_svc records are not checked in general (here).
751
752 Deleting this record doesn't check or delete the svc_* record associated
753 with this record.
754
755 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
756 a DBI database handle is not yet implemented.
757
758 =head1 SEE ALSO
759
760 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
761 schema.html from the base documentation
762
763 =cut
764
765 1;
766