add labels
[freeside.git] / FS / FS / svc_acct.pm
1 package FS::svc_acct;
2 use base qw( FS::svc_Domain_Mixin FS::svc_PBX_Mixin
3              FS::svc_CGP_Mixin FS::svc_CGPRule_Mixin
4              FS::svc_Radius_Mixin
5              FS::svc_Tower_Mixin
6              FS::svc_IP_Mixin
7              FS::Password_Mixin
8              FS::svc_Common
9            );
10
11 use strict;
12 use vars qw( $DEBUG $me $conf $skip_fuzzyfiles
13              $dir_prefix @shells $usernamemin
14              $usernamemax $passwordmin $passwordmax
15              $username_ampersand $username_letter $username_letterfirst
16              $username_noperiod $username_nounderscore $username_nodash
17              $username_uppercase $username_percent $username_colon
18              $username_slash $username_equals $username_pound
19              $username_exclamation
20              $password_noampersand $password_noexclamation
21              $warning_msgnum
22              $smtpmachine
23              $radius_password $radius_ip
24              $dirhash
25              @saltset @pw_set );
26 use Scalar::Util qw( blessed );
27 use Math::BigInt;
28 use Carp;
29 use Fcntl qw(:flock);
30 use Date::Format;
31 use Crypt::PasswdMD5 1.2;
32 use Digest::SHA 'sha1_base64';
33 use Digest::MD5 'md5_base64';
34 use Data::Dumper;
35 use Text::Template;
36 use Authen::Passphrase;
37 use FS::UID qw( datasrc driver_name );
38 use FS::Conf;
39 use FS::Record qw( qsearch qsearchs fields dbh dbdef );
40 use FS::Msgcat qw(gettext);
41 use FS::UI::bytecount;
42 use FS::UI::Web;
43 use FS::PagedSearch qw( psearch ); # XXX in v4, replace with FS::Cursor
44 use FS::part_pkg;
45 use FS::part_svc;
46 use FS::svc_acct_pop;
47 use FS::svc_domain;
48 use FS::svc_pbx;
49 use FS::raddb;
50 use FS::queue;
51 use FS::radius_usergroup;
52 use FS::radius_group;
53 use FS::export_svc;
54 use FS::part_export;
55 use FS::svc_forward;
56 use FS::svc_www;
57 use FS::cdr;
58 use FS::tower_sector;
59
60 $DEBUG = 0;
61 $me = '[FS::svc_acct]';
62
63 #ask FS::UID to run this stuff for us later
64 FS::UID->install_callback( sub { 
65   $conf = new FS::Conf;
66   $dir_prefix = $conf->config('home');
67   @shells = $conf->config('shells');
68   $usernamemin = $conf->config('usernamemin') || 2;
69   $usernamemax = $conf->config('usernamemax');
70   $passwordmin = $conf->config('passwordmin');
71   #blank->8, keep 0
72   $passwordmin = ( defined($passwordmin) && $passwordmin =~ /\d+/ )
73                    ? $passwordmin
74                    : 8;
75   $passwordmax = $conf->config('passwordmax') || 12;
76   $username_letter = $conf->exists('username-letter');
77   $username_letterfirst = $conf->exists('username-letterfirst');
78   $username_noperiod = $conf->exists('username-noperiod');
79   $username_nounderscore = $conf->exists('username-nounderscore');
80   $username_nodash = $conf->exists('username-nodash');
81   $username_uppercase = $conf->exists('username-uppercase');
82   $username_ampersand = $conf->exists('username-ampersand');
83   $username_percent = $conf->exists('username-percent');
84   $username_colon = $conf->exists('username-colon');
85   $username_slash = $conf->exists('username-slash');
86   $username_equals = $conf->exists('username-equals');
87   $username_pound = $conf->exists('username-pound');
88   $username_exclamation = $conf->exists('username-exclamation');
89   $password_noampersand = $conf->exists('password-noexclamation');
90   $password_noexclamation = $conf->exists('password-noexclamation');
91   $dirhash = $conf->config('dirhash') || 0;
92   $warning_msgnum = $conf->config('threshold_warning_msgnum');
93   $smtpmachine = $conf->config('smtpmachine');
94   $radius_password = $conf->config('radius-password') || 'Password';
95   $radius_ip = $conf->config('radius-ip') || 'Framed-IP-Address';
96   @pw_set = FS::svc_acct->pw_set;
97 }
98 );
99
100 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
101
102 sub _cache {
103   my $self = shift;
104   my ( $hashref, $cache ) = @_;
105   if ( $hashref->{'svc_acct_svcnum'} ) {
106     $self->{'_domsvc'} = FS::svc_domain->new( {
107       'svcnum'   => $hashref->{'domsvc'},
108       'domain'   => $hashref->{'svc_acct_domain'},
109       'catchall' => $hashref->{'svc_acct_catchall'},
110     } );
111   }
112 }
113
114 =head1 NAME
115
116 FS::svc_acct - Object methods for svc_acct records
117
118 =head1 SYNOPSIS
119
120   use FS::svc_acct;
121
122   $record = new FS::svc_acct \%hash;
123   $record = new FS::svc_acct { 'column' => 'value' };
124
125   $error = $record->insert;
126
127   $error = $new_record->replace($old_record);
128
129   $error = $record->delete;
130
131   $error = $record->check;
132
133   $error = $record->suspend;
134
135   $error = $record->unsuspend;
136
137   $error = $record->cancel;
138
139   %hash = $record->radius;
140
141   %hash = $record->radius_reply;
142
143   %hash = $record->radius_check;
144
145   $domain = $record->domain;
146
147   $svc_domain = $record->svc_domain;
148
149   $email = $record->email;
150
151   $seconds_since = $record->seconds_since($timestamp);
152
153 =head1 DESCRIPTION
154
155 An FS::svc_acct object represents an account.  FS::svc_acct inherits from
156 FS::svc_Common.  The following fields are currently supported:
157
158 =over 4
159
160 =item svcnum
161
162 Primary key (assigned automatcially for new accounts)
163
164 =item username
165
166 =item _password
167
168 generated if blank
169
170 =item _password_encoding
171
172 plain, crypt, ldap (or empty for autodetection)
173
174 =item sec_phrase
175
176 security phrase
177
178 =item popnum
179
180 Point of presence (see L<FS::svc_acct_pop>)
181
182 =item uid
183
184 =item gid
185
186 =item finger
187
188 GECOS
189
190 =item dir
191
192 set automatically if blank (and uid is not)
193
194 =item shell
195
196 =item quota
197
198 =item slipip
199
200 IP address
201
202 =item seconds
203
204 =item upbytes
205
206 =item downbyte
207
208 =item totalbytes
209
210 =item domsvc
211
212 svcnum from svc_domain
213
214 =item pbxsvc
215
216 Optional svcnum from svc_pbx
217
218 =item radius_I<Radius_Attribute>
219
220 I<Radius-Attribute> (reply)
221
222 =item rc_I<Radius_Attribute>
223
224 I<Radius-Attribute> (check)
225
226 =back
227
228 =head1 METHODS
229
230 =over 4
231
232 =item new HASHREF
233
234 Creates a new account.  To add the account to the database, see L<"insert">.
235
236 =cut
237
238 sub table_info {
239   {
240     'name'   => 'Account',
241     'longname_plural' => 'Access accounts and mailboxes',
242     'sorts' => [ 'username', 'uid', 'seconds', 'last_login' ],
243     'display_weight' => 10,
244     'cancel_weight'  => 50, 
245     'ip_field' => 'slipip',
246     'manual_require' => 1,
247     'fields' => {
248         'dir'       => 'Home directory',
249         'uid'       => {
250                          label    => 'UID',
251                          def_info => 'set to fixed and blank for no UIDs',
252                          type     => 'text',
253                        },
254         'slipip'    => 'IP address',
255     #    'popnum'    => qq!<A HREF="$p/browse/svc_acct_pop.cgi/">POP number</A>!,
256         'popnum'    => {
257                          label => 'Access number',
258                          type => 'select',
259                          select_table => 'svc_acct_pop',
260                          select_key   => 'popnum',
261                          select_label => 'city',
262                          disable_select => 1,
263                        },
264         'username'  => {
265                          label => 'Username',
266                          type => 'text',
267                          disable_default => 1,
268                          disable_fixed => 1,
269                          disable_select => 1,
270                          required => 1,
271                        },
272         'password_selfchange' => { label => 'Password modification',
273                                    type  => 'checkbox',
274                                  },
275         'password_recover'    => { label => 'Password recovery',
276                                    type  => 'checkbox',
277                                  },
278         'quota'     => { 
279                          label => 'Quota', #Mail storage limit
280                          type => 'text',
281                          disable_inventory => 1,
282                        },
283         'file_quota'=> { 
284                          label => 'File storage limit',
285                          type => 'text',
286                          disable_inventory => 1,
287                        },
288         'file_maxnum'=> { 
289                          label => 'Number of files limit',
290                          type => 'text',
291                          disable_inventory => 1,
292                        },
293         'file_maxsize'=> { 
294                          label => 'File size limit',
295                          type => 'text',
296                          disable_inventory => 1,
297                        },
298         '_password' => { label => 'Password',
299           #required => 1
300                        },
301         'gid'       => {
302                          label    => 'GID',
303                          def_info => 'when blank, defaults to UID',
304                          type     => 'text',
305                        },
306         'shell'     => {
307                          label    => 'Shell',
308                          def_info => 'set to blank for no shell tracking',
309                          type     => 'select',
310                          #select_list => [ $conf->config('shells') ],
311                          select_list => [ $conf ? $conf->config('shells') : () ],
312                          disable_inventory => 1,
313                          disable_select => 1,
314                        },
315         'finger'    => 'Real name', # (GECOS)',
316         'domsvc'    => {
317                          label     => 'Domain',
318                          type      => 'select',
319                          select_svc => 1,
320                          select_table => 'svc_domain',
321                          select_key   => 'svcnum',
322                          select_label => 'domain',
323                          disable_inventory => 1,
324                          required => 1,
325                        },
326         'pbxsvc'    => { label => 'PBX',
327                          type  => 'select-svc_pbx.html',
328                          disable_inventory => 1,
329                          disable_select => 1, #UI wonky, pry works otherwise
330                        },
331         'sectornum' => 'Tower sector',
332         'routernum' => 'Router/block',
333         'blocknum'  => {
334                          'label' => 'Address block',
335                          'type'  => 'select',
336                          'select_table' => 'addr_block',
337                           'select_key'   => 'blocknum',
338                          'select_label' => 'cidr',
339                          'disable_inventory' => 1,
340                        },
341         'usergroup' => {
342                          label => 'RADIUS groups',
343                          type  => 'select-radius_group.html',
344                          disable_inventory => 1,
345                          disable_select => 1,
346                          multiple => 1,
347                        },
348         'seconds'   => { label => 'Seconds',
349                          label_sort => 'with Time Remaining',
350                          type  => 'text',
351                          disable_inventory => 1,
352                          disable_select => 1,
353                          disable_part_svc_column => 1,
354                        },
355         'upbytes'   => { label => 'Upload',
356                          type  => 'text',
357                          disable_inventory => 1,
358                          disable_select => 1,
359                          'format' => \&FS::UI::bytecount::display_bytecount,
360                          'parse' => \&FS::UI::bytecount::parse_bytecount,
361                          disable_part_svc_column => 1,
362                        },
363         'downbytes' => { label => 'Download',
364                          type  => 'text',
365                          disable_inventory => 1,
366                          disable_select => 1,
367                          'format' => \&FS::UI::bytecount::display_bytecount,
368                          'parse' => \&FS::UI::bytecount::parse_bytecount,
369                          disable_part_svc_column => 1,
370                        },
371         'totalbytes'=> { label => 'Total up and download',
372                          type  => 'text',
373                          disable_inventory => 1,
374                          disable_select => 1,
375                          'format' => \&FS::UI::bytecount::display_bytecount,
376                          'parse' => \&FS::UI::bytecount::parse_bytecount,
377                          disable_part_svc_column => 1,
378                        },
379         'seconds_threshold'   => { label => 'Seconds threshold',
380                                    type  => 'text',
381                                    disable_inventory => 1,
382                                    disable_select => 1,
383                                    disable_part_svc_column => 1,
384                                  },
385         'upbytes_threshold'   => { label => 'Upload threshold',
386                                    type  => 'text',
387                                    disable_inventory => 1,
388                                    disable_select => 1,
389                                    'format' => \&FS::UI::bytecount::display_bytecount,
390                                    'parse' => \&FS::UI::bytecount::parse_bytecount,
391                                    disable_part_svc_column => 1,
392                                  },
393         'downbytes_threshold' => { label => 'Download threshold',
394                                    type  => 'text',
395                                    disable_inventory => 1,
396                                    disable_select => 1,
397                                    'format' => \&FS::UI::bytecount::display_bytecount,
398                                    'parse' => \&FS::UI::bytecount::parse_bytecount,
399                                    disable_part_svc_column => 1,
400                                  },
401         'totalbytes_threshold'=> { label => 'Total up and download threshold',
402                                    type  => 'text',
403                                    disable_inventory => 1,
404                                    disable_select => 1,
405                                    'format' => \&FS::UI::bytecount::display_bytecount,
406                                    'parse' => \&FS::UI::bytecount::parse_bytecount,
407                                    disable_part_svc_column => 1,
408                                  },
409         'last_login'=>           {
410                                    label     => 'Last login',
411                                    type      => 'disabled',
412                                  },
413         'last_logout'=>          {
414                                    label     => 'Last logout',
415                                    type      => 'disabled',
416                                  },
417
418         'cgp_aliases' => { 
419                            label => 'Communigate aliases',
420                            type  => 'text',
421                            disable_inventory => 1,
422                            disable_select    => 1,
423                          },
424         #settings
425         'cgp_type'=> { 
426                        label => 'Communigate account type',
427                        type => 'select',
428                        select_list => [qw( MultiMailbox TextMailbox MailDirMailbox AGrade BGrade CGrade )],
429                        disable_inventory => 1,
430                        disable_select    => 1,
431                      },
432         'cgp_accessmodes' => { 
433                                label => 'Communigate enabled services',
434                                type  => 'communigate_pro-accessmodes',
435                                disable_inventory => 1,
436                                disable_select    => 1,
437                              },
438         'cgp_rulesallowed'   => {
439           label       => 'Allowed mail rules',
440           type        => 'select',
441           select_list => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
442           disable_inventory => 1,
443           disable_select    => 1,
444         },
445         'cgp_rpopallowed'    => { label => 'RPOP modifications',
446                                   type  => 'checkbox',
447                                 },
448         'cgp_mailtoall'      => { label => 'Accepts mail to "all"',
449                                   type  => 'checkbox',
450                                 },
451         'cgp_addmailtrailer' => { label => 'Add trailer to sent mail',
452                                   type  => 'checkbox',
453                                 },
454         'cgp_archiveafter'   => {
455           label       => 'Archive messages after',
456           type        => 'select',
457           select_hash => [ 
458                            -2 => 'default(730 days)',
459                            0 => 'Never',
460                            86400 => '24 hours',
461                            172800 => '2 days',
462                            259200 => '3 days',
463                            432000 => '5 days',
464                            604800 => '7 days',
465                            1209600 => '2 weeks',
466                            2592000 => '30 days',
467                            7776000 => '90 days',
468                            15552000 => '180 days',
469                            31536000 => '365 days',
470                            63072000 => '730 days',
471                          ],
472           disable_inventory => 1,
473           disable_select    => 1,
474         },
475         #XXX mailing lists
476
477         #preferences
478         'cgp_deletemode' => { 
479                               label => 'Communigate message delete method',
480                               type  => 'select',
481                               select_list => [ 'Move To Trash', 'Immediately', 'Mark' ],
482                               disable_inventory => 1,
483                               disable_select    => 1,
484                             },
485         'cgp_emptytrash' => { 
486                               label     => 'Communigate on logout remove trash',
487                               type        => 'select',
488                               select_list => __PACKAGE__->cgp_emptytrash_values,
489                               disable_inventory => 1,
490                               disable_select    => 1,
491                             },
492         'cgp_language' => {
493                             label => 'Communigate language',
494                             type  => 'select',
495                             select_list => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
496                             disable_inventory => 1,
497                             disable_select    => 1,
498                           },
499         'cgp_timezone' => {
500                             label       => 'Communigate time zone',
501                             type        => 'select',
502                             select_list => __PACKAGE__->cgp_timezone_values,
503                             disable_inventory => 1,
504                             disable_select    => 1,
505                           },
506         'cgp_skinname' => {
507                             label => 'Communigate layout',
508                             type  => 'select',
509                             select_list => [ '', '***', 'GoldFleece', 'Skin2' ],
510                             disable_inventory => 1,
511                             disable_select    => 1,
512                           },
513         'cgp_prontoskinname' => {
514                             label => 'Communigate Pronto style',
515                             type  => 'select',
516                             select_list => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
517                             disable_inventory => 1,
518                             disable_select    => 1,
519                           },
520         'cgp_sendmdnmode' => {
521           label => 'Communigate send read receipts',
522           type  => 'select',
523           select_list => [ '', 'Never', 'Manually', 'Automatically' ],
524           disable_inventory => 1,
525           disable_select    => 1,
526         },
527
528         #mail
529         #XXX RPOP settings
530
531     },
532   };
533 }
534
535 sub table { 'svc_acct'; }
536
537 sub table_dupcheck_fields { ( 'username', 'domsvc' ); }
538
539 sub last_login {
540   shift->_lastlog('in', @_);
541 }
542
543 sub last_logout {
544   shift->_lastlog('out', @_);
545 }
546
547 sub _lastlog {
548   my( $self, $op, $time ) = @_;
549
550   if ( defined($time) ) {
551     warn "$me last_log$op called on svcnum ". $self->svcnum.
552          ' ('. $self->email. "): $time\n"
553       if $DEBUG;
554
555     my $dbh = dbh;
556
557     my $sql = "UPDATE svc_acct SET last_log$op = ? WHERE svcnum = ?";
558     warn "$me $sql\n"
559       if $DEBUG;
560
561     my $sth = $dbh->prepare( $sql )
562       or die "Error preparing $sql: ". $dbh->errstr;
563     my $rv = $sth->execute($time, $self->svcnum);
564     die "Error executing $sql: ". $sth->errstr
565       unless defined($rv);
566     die "Can't update last_log$op for svcnum". $self->svcnum
567       if $rv == 0;
568
569     $self->{'Hash'}->{"last_log$op"} = $time;
570   }else{
571     $self->getfield("last_log$op");
572   }
573 }
574
575 =item search_sql STRING
576
577 Class method which returns an SQL fragment to search for the given string.
578
579 =cut
580
581 sub search_sql {
582   my( $class, $string ) = @_;
583   if ( $string =~ /^([^@]+)@([^@]+)$/ ) {
584     my( $username, $domain ) = ( $1, $2 );
585     my $q_username = dbh->quote($username);
586     my @svc_domain = qsearch('svc_domain', { 'domain' => $domain } );
587     if ( @svc_domain ) {
588       "svc_acct.username = $q_username AND ( ".
589         join( ' OR ', map { "svc_acct.domsvc = ". $_->svcnum; } @svc_domain ).
590       " )";
591     } else {
592       '1 = 0'; #false
593     }
594   } elsif ( $string =~ /^(\d{1,3}\.){3}\d{1,3}$/ ) {
595     ' ( '.
596       $class->search_sql_field('slipip',   $string ).
597     ' OR '.
598       $class->search_sql_field('username', $string ).
599     ' ) ';
600   } else {
601     $class->search_sql_field('username', $string);
602   }
603 }
604
605 =item label [ END_TIMESTAMP [ START_TIMESTAMP ] ]
606
607 Returns the "username@domain" string for this account.
608
609 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
610 history records.
611
612 =cut
613
614 sub label {
615   my $self = shift;
616   $self->email(@_);
617 }
618
619 =item label_long [ END_TIMESTAMP [ START_TIMESTAMP ] ]
620
621 Returns a longer string label for this acccount ("Real Name <username@domain>"
622 if available, or "username@domain").
623
624 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
625 history records.
626
627 =cut
628
629 sub label_long {
630   my $self = shift;
631   my $label = $self->label(@_);
632   my $finger = $self->finger;
633   return $label unless $finger =~ /\S/;
634   my $maxlen = 40 - length($label) - length($self->cust_svc->part_svc->svc);
635   $finger = substr($finger, 0, $maxlen-3).'...' if length($finger) > $maxlen;
636   "$finger <$label>";
637 }
638
639 =item insert [ , OPTION => VALUE ... ]
640
641 Adds this account to the database.  If there is an error, returns the error,
642 otherwise returns false.
643
644 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be 
645 defined.  An FS::cust_svc record will be created and inserted.
646
647 The additional field I<usergroup> can optionally be defined; if so it should
648 contain an arrayref of group names.  See L<FS::radius_usergroup>.
649
650 The additional field I<child_objects> can optionally be defined; if so it
651 should contain an arrayref of FS::tablename objects.  They will have their
652 svcnum fields set and will be inserted after this record, but before any
653 exports are run.  Each element of the array can also optionally be a
654 two-element array reference containing the child object and the name of an
655 alternate field to be filled in with the newly-inserted svcnum, for example
656 C<[ $svc_forward, 'srcsvc' ]>
657
658 Currently available options are: I<depend_jobnum>
659
660 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
661 jobnums), all provisioning jobs will have a dependancy on the supplied
662 jobnum(s) (they will not run until the specific job(s) complete(s)).
663
664 (TODOC: L<FS::queue> and L<freeside-queued>)
665
666 (TODOC: new exports!)
667
668 =cut
669
670 sub insert {
671   my $self = shift;
672   my %options = @_;
673
674   if ( $DEBUG ) {
675     warn "[$me] insert called on $self: ". Dumper($self).
676          "\nwith options: ". Dumper(%options);
677   }
678
679   local $SIG{HUP} = 'IGNORE';
680   local $SIG{INT} = 'IGNORE';
681   local $SIG{QUIT} = 'IGNORE';
682   local $SIG{TERM} = 'IGNORE';
683   local $SIG{TSTP} = 'IGNORE';
684   local $SIG{PIPE} = 'IGNORE';
685
686   my $oldAutoCommit = $FS::UID::AutoCommit;
687   local $FS::UID::AutoCommit = 0;
688   my $dbh = dbh;
689
690   my @jobnums;
691   my $error = $self->SUPER::insert( # usergroup is here
692     'jobnums'       => \@jobnums,
693     'child_objects' => $self->child_objects,
694     %options,
695   );
696
697   $error ||= $self->insert_password_history;
698
699   if ( $error ) {
700     $dbh->rollback if $oldAutoCommit;
701     return $error;
702   }
703
704   unless ( $skip_fuzzyfiles ) {
705     $error = $self->queue_fuzzyfiles_update;
706     if ( $error ) {
707       $dbh->rollback if $oldAutoCommit;
708       return "updating fuzzy search cache: $error";
709     }
710   }
711
712   my $cust_pkg = $self->cust_svc->cust_pkg;
713
714   if ( $cust_pkg ) {
715     my $cust_main = $cust_pkg->cust_main;
716     my $agentnum = $cust_main->agentnum;
717
718     if (   $conf->exists('emailinvoiceautoalways')
719         || $conf->exists('emailinvoiceauto')
720         && ! $cust_main->invoicing_list_emailonly
721        ) {
722
723       # slight false laziness w/ edit/process/cust_main.cgi...
724       # and also slightly arbitrary behavior.
725       #
726       # this will never happen but check it anyway
727       my ($contact) = map { $_->contact }
728         qsearch('contact_email', { emailaddress => $self->email });
729
730       if (!$contact) {
731         # if the "real name" of this account matches the first + last name
732         # of a contact, attach the email address to that person.
733         my @contacts = map { $_->contact } $cust_main->cust_contact;
734         my $myname = $self->get('finger');
735         my ($contact) =
736           grep { $_->get('first') . ' ' . $_->get('last') eq $myname } @contacts;
737         # otherwise just pick the first one
738         $contact = $contacts[0];
739       }
740       # if there is one
741       $contact ||= FS::contact->new({
742           'custnum'       => $cust_main->get('custnum'),
743           'locationnum'   => $cust_main->get('bill_locationnum'),
744           'last'          => $cust_main->get('last'),
745           'first'         => $cust_main->get('first'),
746       });
747       $contact->set('emailaddress', $self->email);
748       $contact->set('invoice_dest', 'Y');
749
750       if ( $contact->get('contactnum') ) {
751         $error = $contact->replace;
752       } else {
753         $error = $contact->insert;
754       }
755
756       if ( $error ) {
757         $dbh->rollback if $oldAutoCommit;
758         return "creating invoice destination contact: $error";
759       }
760     }
761
762   } # if $cust_pkg
763
764   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
765   ''; #no error
766 }
767
768 # set usage fields and thresholds if unset but set in a package def
769 # AND the package already has a last bill date (otherwise they get double added)
770 sub preinsert_hook_first {
771   my $self = shift;
772
773   return '' unless $self->pkgnum;
774
775   my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
776   return '' unless $cust_pkg && $cust_pkg->last_bill;
777
778   my $part_pkg = $cust_pkg->part_pkg;
779   return '' unless $part_pkg && $part_pkg->can('usage_valuehash');
780
781   my %values = $part_pkg->usage_valuehash;
782   my $multiplier = $conf->exists('svc_acct-usage_threshold') 
783                      ? 1 - $conf->config('svc_acct-usage_threshold')/100
784                      : 0.20; #doesn't matter
785
786   foreach ( keys %values ) {
787     next if $self->getfield($_);
788     $self->setfield( $_, $values{$_} );
789     $self->setfield( $_. '_threshold', int( $values{$_} * $multiplier ) )
790       if $conf->exists('svc_acct-usage_threshold');
791   }
792
793   ''; #no error
794 }
795
796 =item delete
797
798 Deletes this account from the database.  If there is an error, returns the
799 error, otherwise returns false.
800
801 The corresponding FS::cust_svc record will be deleted as well.
802
803 (TODOC: new exports!)
804
805 =cut
806
807 sub delete {
808   my $self = shift;
809
810   return "can't delete system account" if $self->_check_system;
811
812   return "Can't delete an account which is a (svc_forward) source!"
813     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
814
815   return "Can't delete an account which is a (svc_forward) destination!"
816     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
817
818   return "Can't delete an account with (svc_www) web service!"
819     if qsearch( 'svc_www', { 'usersvc' => $self->svcnum } );
820
821   # what about records in session ? (they should refer to history table)
822
823   local $SIG{HUP} = 'IGNORE';
824   local $SIG{INT} = 'IGNORE';
825   local $SIG{QUIT} = 'IGNORE';
826   local $SIG{TERM} = 'IGNORE';
827   local $SIG{TSTP} = 'IGNORE';
828   local $SIG{PIPE} = 'IGNORE';
829
830   my $oldAutoCommit = $FS::UID::AutoCommit;
831   local $FS::UID::AutoCommit = 0;
832   my $dbh = dbh;
833
834   foreach my $svc_domain (
835     qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
836   ) {
837     my %hash = new FS::svc_domain->hash;
838     $hash{'catchall'} = '';
839     my $new = new FS::svc_domain \%hash;
840     my $error = $new->replace($svc_domain);
841     if ( $error ) {
842       $dbh->rollback if $oldAutoCommit;
843       return $error;
844     }
845   }
846
847   foreach my $svc_phone (
848     qsearch( 'svc_phone', { 'forward_svcnum' => $self->svcnum })
849   ) {
850     $svc_phone->set('forward_svcnum', '');
851     my $error = $svc_phone->replace;
852     if ( $error ) {
853       $dbh->rollback if $oldAutoCommit;
854       return $error;
855     }
856   }
857
858   my $error = $self->delete_password_history
859            || $self->SUPER::delete; # usergroup here
860   if ( $error ) {
861     $dbh->rollback if $oldAutoCommit;
862     return $error;
863   }
864
865   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
866   '';
867 }
868
869 =item replace OLD_RECORD
870
871 Replaces OLD_RECORD with this one in the database.  If there is an error,
872 returns the error, otherwise returns false.
873
874 The additional field I<usergroup> can optionally be defined; if so it should
875 contain an arrayref of group names.  See L<FS::radius_usergroup>.
876
877
878 =cut
879
880 sub replace {
881   my $new = shift;
882
883   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
884               ? shift
885               : $new->replace_old;
886
887   warn "$me replacing $old with $new\n" if $DEBUG;
888
889   my $error;
890
891   return "can't modify system account" if $old->_check_system;
892
893   {
894     #no warnings 'numeric';  #alas, a 5.006-ism
895     local($^W) = 0;
896
897     foreach my $xid (qw( uid gid )) {
898
899       return "Can't change $xid!"
900         if ! $conf->exists("svc_acct-edit_$xid")
901            && $old->$xid() != $new->$xid()
902            && $new->cust_svc->part_svc->part_svc_column($xid)->columnflag ne 'F'
903     }
904
905   }
906
907   return "can't change username"
908     if $old->username ne $new->username
909     && $conf->exists('svc_acct-no_edit_username');
910
911   #change homdir when we change username
912   $new->setfield('dir', '') if $old->username ne $new->username;
913
914   local $SIG{HUP} = 'IGNORE';
915   local $SIG{INT} = 'IGNORE';
916   local $SIG{QUIT} = 'IGNORE';
917   local $SIG{TERM} = 'IGNORE';
918   local $SIG{TSTP} = 'IGNORE';
919   local $SIG{PIPE} = 'IGNORE';
920
921   my $oldAutoCommit = $FS::UID::AutoCommit;
922   local $FS::UID::AutoCommit = 0;
923   my $dbh = dbh;
924
925   $error = $new->SUPER::replace($old, @_); # usergroup here
926
927   # don't need to record this unless the password was changed
928   if ( $old->_password ne $new->_password ) {
929     $error ||= $new->insert_password_history;
930   }
931
932   if ( $error ) {
933     $dbh->rollback if $oldAutoCommit;
934     return $error if $error;
935   }
936
937   if ( $new->username ne $old->username && ! $skip_fuzzyfiles ) {
938     $error = $new->queue_fuzzyfiles_update;
939     if ( $error ) {
940       $dbh->rollback if $oldAutoCommit;
941       return "updating fuzzy search cache: $error";
942     }
943   }
944
945   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
946   ''; #no error
947 }
948
949 =item queue_fuzzyfiles_update
950
951 Used by insert & replace to update the fuzzy search cache
952
953 =cut
954
955 sub queue_fuzzyfiles_update {
956   my $self = shift;
957
958   local $SIG{HUP} = 'IGNORE';
959   local $SIG{INT} = 'IGNORE';
960   local $SIG{QUIT} = 'IGNORE';
961   local $SIG{TERM} = 'IGNORE';
962   local $SIG{TSTP} = 'IGNORE';
963   local $SIG{PIPE} = 'IGNORE';
964
965   my $oldAutoCommit = $FS::UID::AutoCommit;
966   local $FS::UID::AutoCommit = 0;
967   my $dbh = dbh;
968
969   my $queue = new FS::queue {
970     'svcnum' => $self->svcnum,
971     'job'    => 'FS::svc_acct::append_fuzzyfiles'
972   };
973   my $error = $queue->insert($self->username);
974   if ( $error ) {
975     $dbh->rollback if $oldAutoCommit;
976     return "queueing job (transaction rolled back): $error";
977   }
978
979   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
980   '';
981
982 }
983
984
985 =item suspend
986
987 Suspends this account by calling export-specific suspend hooks.  If there is
988 an error, returns the error, otherwise returns false.
989
990 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
991
992 =cut
993
994 sub suspend {
995   my $self = shift;
996   return "can't suspend system account" if $self->_check_system;
997   $self->SUPER::suspend(@_);
998 }
999
1000 =item unsuspend
1001
1002 Unsuspends this account by by calling export-specific suspend hooks.  If there
1003 is an error, returns the error, otherwise returns false.
1004
1005 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
1006
1007 =cut
1008
1009 sub unsuspend {
1010   my $self = shift;
1011   my %hash = $self->hash;
1012   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
1013     $hash{_password} = $1;
1014     my $new = new FS::svc_acct ( \%hash );
1015     my $error = $new->replace($self);
1016     return $error if $error;
1017   }
1018
1019   $self->SUPER::unsuspend(@_);
1020 }
1021
1022 =item cancel
1023
1024 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
1025
1026 If the B<auto_unset_catchall> configuration option is set, this method will
1027 automatically remove any references to the canceled service in the catchall
1028 field of svc_domain.  This allows packages that contain both a svc_domain and
1029 its catchall svc_acct to be canceled in one step.
1030
1031 =cut
1032
1033 sub cancel {
1034   # Only one thing to do at this level
1035   my $self = shift;
1036   foreach my $svc_domain (
1037       qsearch( 'svc_domain', { catchall => $self->svcnum } ) ) {
1038     if($conf->exists('auto_unset_catchall')) {
1039       my %hash = $svc_domain->hash;
1040       $hash{catchall} = '';
1041       my $new = new FS::svc_domain ( \%hash );
1042       my $error = $new->replace($svc_domain);
1043       return $error if $error;
1044     } else {
1045       return "cannot unprovision svc_acct #".$self->svcnum.
1046           " while assigned as catchall for svc_domain #".$svc_domain->svcnum;
1047     }
1048   }
1049
1050   $self->SUPER::cancel(@_);
1051 }
1052
1053
1054 =item check
1055
1056 Checks all fields to make sure this is a valid service.  If there is an error,
1057 returns the error, otherwise returns false.  Called by the insert and replace
1058 methods.
1059
1060 Sets any fixed values; see L<FS::part_svc>.
1061
1062 =cut
1063
1064 sub check {
1065   my $self = shift;
1066
1067   my($recref) = $self->hashref;
1068
1069   my $x = $self->setfixed;
1070   return $x unless ref($x);
1071   my $part_svc = $x;
1072
1073   my $error = $self->ut_numbern('svcnum')
1074               #|| $self->ut_number('domsvc')
1075               || $self->ut_foreign_key( 'domsvc', 'svc_domain', 'svcnum' )
1076               || $self->ut_foreign_keyn('pbxsvc', 'svc_pbx',    'svcnum' )
1077               || $self->ut_foreign_keyn('sectornum','tower_sector','sectornum')
1078               || $self->ut_foreign_keyn('routernum','router','routernum')
1079               || $self->ut_foreign_keyn('blocknum','addr_block','blocknum')
1080               || $self->ut_textn('sec_phrase')
1081               || $self->ut_snumbern('seconds')
1082               || $self->ut_snumbern('upbytes')
1083               || $self->ut_snumbern('downbytes')
1084               || $self->ut_snumbern('totalbytes')
1085               || $self->ut_snumbern('seconds_threshold')
1086               || $self->ut_snumbern('upbytes_threshold')
1087               || $self->ut_snumbern('downbytes_threshold')
1088               || $self->ut_snumbern('totalbytes_threshold')
1089               || $self->ut_enum('_password_encoding', ['',qw(plain crypt ldap)])
1090               || $self->ut_enum('password_selfchange', [ '', 'Y' ])
1091               || $self->ut_enum('password_recover',    [ '', 'Y' ])
1092               #cardfortress
1093               || $self->ut_anything('cf_privatekey')
1094               #communigate
1095               || $self->ut_textn('cgp_accessmodes')
1096               || $self->ut_alphan('cgp_type')
1097               || $self->ut_textn('cgp_aliases' ) #well
1098               # settings
1099               || $self->ut_alphasn('cgp_rulesallowed')
1100               || $self->ut_enum('cgp_rpopallowed', [ '', 'Y' ])
1101               || $self->ut_enum('cgp_mailtoall', [ '', 'Y' ])
1102               || $self->ut_enum('cgp_addmailtrailer', [ '', 'Y' ])
1103               || $self->ut_snumbern('cgp_archiveafter')
1104               # preferences
1105               || $self->ut_alphasn('cgp_deletemode')
1106               || $self->ut_enum('cgp_emptytrash', $self->cgp_emptytrash_values)
1107               || $self->ut_alphan('cgp_language')
1108               || $self->ut_textn('cgp_timezone')
1109               || $self->ut_textn('cgp_skinname')
1110               || $self->ut_textn('cgp_prontoskinname')
1111               || $self->ut_alphan('cgp_sendmdnmode')
1112   ;
1113   return $error if $error;
1114
1115   # assign IP address, etc.
1116   if ( $conf->exists('svc_acct-ip_addr') ) {
1117     my $error = $self->svc_ip_check;
1118     return $error if $error;
1119   } else { # I think this is correct
1120     $self->routernum('');
1121     $self->blocknum('');
1122   }
1123
1124   my $cust_pkg;
1125   local $username_letter = $username_letter;
1126   local $username_uppercase = $username_uppercase;
1127   if ($self->svcnum) {
1128     my $cust_svc = $self->cust_svc
1129       or return "no cust_svc record found for svcnum ". $self->svcnum;
1130     my $cust_pkg = $cust_svc->cust_pkg;
1131   }
1132   if ($self->pkgnum) {
1133     $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $self->pkgnum } );#complain?
1134   }
1135   if ($cust_pkg) {
1136     $username_letter =
1137       $conf->exists('username-letter', $cust_pkg->cust_main->agentnum);
1138     $username_uppercase =
1139       $conf->exists('username-uppercase', $cust_pkg->cust_main->agentnum);
1140   }
1141
1142   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
1143
1144   $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:\/\=\#\!]{$usernamemin,$ulen})$/i
1145     or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
1146   $recref->{username} = $1;
1147
1148   my $uerror = gettext('illegal_username'). ': '. $recref->{username};
1149
1150   unless ( $username_uppercase ) {
1151     $recref->{username} =~ /[A-Z]/ and return $uerror;
1152   }
1153   if ( $username_letterfirst ) {
1154     $recref->{username} =~ /^[a-z]/ or return $uerror;
1155   } elsif ( $username_letter ) {
1156     $recref->{username} =~ /[a-z]/ or return $uerror;
1157   }
1158   if ( $username_noperiod ) {
1159     $recref->{username} =~ /\./ and return $uerror;
1160   }
1161   if ( $username_nounderscore ) {
1162     $recref->{username} =~ /_/ and return $uerror;
1163   }
1164   if ( $username_nodash ) {
1165     $recref->{username} =~ /\-/ and return $uerror;
1166   }
1167   unless ( $username_ampersand ) {
1168     $recref->{username} =~ /\&/ and return $uerror;
1169   }
1170   unless ( $username_percent ) {
1171     $recref->{username} =~ /\%/ and return $uerror;
1172   }
1173   unless ( $username_colon ) {
1174     $recref->{username} =~ /\:/ and return $uerror;
1175   }
1176   unless ( $username_slash ) {
1177     $recref->{username} =~ /\// and return $uerror;
1178   }
1179   unless ( $username_equals ) {
1180     $recref->{username} =~ /\=/ and return $uerror;
1181   }
1182   unless ( $username_pound ) {
1183     $recref->{username} =~ /\#/ and return $uerror;
1184   }
1185   unless ( $username_exclamation ) {
1186     $recref->{username} =~ /\!/ and return $uerror;
1187   }
1188
1189
1190   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
1191   $recref->{popnum} = $1;
1192   return "Unknown popnum" unless
1193     ! $recref->{popnum} ||
1194     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
1195
1196   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
1197
1198     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
1199     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
1200
1201     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
1202     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
1203     #not all systems use gid=uid
1204     #you can set a fixed gid in part_svc
1205
1206     return "Only root can have uid 0"
1207       if $recref->{uid} == 0
1208          && $recref->{username} !~ /^(root|toor|smtp)$/;
1209
1210     unless ( $recref->{username} eq 'sync' ) {
1211       if ( grep $_ eq $recref->{shell}, @shells ) {
1212         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
1213       } else {
1214         return "Illegal shell \`". $self->shell. "\'; ".
1215                "shells configuration value contains: @shells";
1216       }
1217     } else {
1218       $recref->{shell} = '/bin/sync';
1219     }
1220
1221   } else {
1222     $recref->{gid} ne '' ? 
1223       return "Can't have gid without uid" : ( $recref->{gid}='' );
1224     #$recref->{dir} ne '' ? 
1225     #  return "Can't have directory without uid" : ( $recref->{dir}='' );
1226     $recref->{shell} ne '' ? 
1227       return "Can't have shell without uid" : ( $recref->{shell}='' );
1228   }
1229
1230   unless ( $part_svc->part_svc_column('dir')->columnflag eq 'F' ) {
1231
1232     $recref->{dir} =~ /^([\/\w\-\.\&\:\#]*)$/
1233       or return "Illegal directory: ". $recref->{dir};
1234     $recref->{dir} = $1;
1235     return "Illegal directory"
1236       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
1237     return "Illegal directory"
1238       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
1239     unless ( $recref->{dir} ) {
1240       $recref->{dir} = $dir_prefix . '/';
1241       if ( $dirhash > 0 ) {
1242         for my $h ( 1 .. $dirhash ) {
1243           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
1244         }
1245       } elsif ( $dirhash < 0 ) {
1246         for my $h ( reverse $dirhash .. -1 ) {
1247           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
1248         }
1249       }
1250       $recref->{dir} .= $recref->{username};
1251     ;
1252     }
1253
1254   }
1255
1256   if ( $self->getfield('finger') eq '' ) {
1257     my $cust_pkg = $self->svcnum
1258       ? $self->cust_svc->cust_pkg
1259       : qsearchs('cust_pkg', { 'pkgnum' => $self->getfield('pkgnum') } );
1260     if ( $cust_pkg ) {
1261       my $cust_main = $cust_pkg->cust_main;
1262       $self->setfield('finger', $cust_main->first.' '.$cust_main->get('last') );
1263     }
1264   }
1265   #  $error = $self->ut_textn('finger');
1266   #  return $error if $error;
1267   $self->getfield('finger') =~ /^([\w \,\.\-\'\&\t\!\@\#\$\%\(\)\+\;\"\?\/\*\<\>]*)$/
1268       or return "Illegal finger: ". $self->getfield('finger');
1269   $self->setfield('finger', $1);
1270
1271   for (qw( quota file_quota file_maxsize )) {
1272     $recref->{$_} =~ /^(\w*)$/ or return "Illegal $_";
1273     $recref->{$_} = $1;
1274   }
1275   $recref->{file_maxnum} =~ /^\s*(\d*)\s*$/ or return "Illegal file_maxnum";
1276   $recref->{file_maxnum} = $1;
1277
1278   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
1279     if ( $recref->{slipip} eq '' ) {
1280       $recref->{slipip} = ''; # eh?
1281     } elsif ( $recref->{slipip} eq '0e0' ) {
1282       $recref->{slipip} = '0e0';
1283     } else {
1284       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
1285         or return "Illegal slipip: ". $self->slipip;
1286       $recref->{slipip} = $1;
1287     }
1288   }
1289
1290   #arbitrary RADIUS stuff; allow ut_textn for now
1291   foreach ( grep /^radius_/, fields('svc_acct') ) {
1292     $self->ut_textn($_);
1293   }
1294
1295   # First, if _password is blank, generate one and set default encoding.
1296   if ( ! $recref->{_password} ) {
1297     $error = $self->set_password('');
1298   }
1299   # But if there's a _password but no encoding, assume it's plaintext and 
1300   # set it to default encoding.
1301   elsif ( ! $recref->{_password_encoding} ) {
1302     $error = $self->set_password($recref->{_password});
1303   }
1304   return $error if $error;
1305
1306   # Next, check _password to ensure compliance with the encoding.
1307   if ( $recref->{_password_encoding} eq 'ldap' ) {
1308
1309     if ( $recref->{_password} =~ /^(\{[\w\-]+\})(!?.{0,64})$/ ) {
1310       $recref->{_password} = uc($1).$2;
1311     } else {
1312       return 'Illegal (ldap-encoded) password: '. $recref->{_password};
1313     }
1314
1315   } elsif ( $recref->{_password_encoding} eq 'crypt' ) {
1316
1317     if ( $recref->{_password} =~
1318            #/^(\$\w+\$.*|[\w\+\/]{13}|_[\w\+\/]{19}|\*)$/
1319            /^(!!?)?(\$\w+\$.*|[\w\+\/\.]{13}|_[\w\+\/\.]{19}|\*)$/
1320        ) {
1321
1322       $recref->{_password} = ( defined($1) ? $1 : '' ). $2;
1323
1324     } else {
1325       return 'Illegal (crypt-encoded) password: '. $recref->{_password};
1326     }
1327
1328   } elsif ( $recref->{_password_encoding} eq 'plain' ) { 
1329     # Password randomization is now in set_password.
1330     # Strip whitespace characters, check length requirements, etc.
1331     if ( $recref->{_password} =~ /^([^\t\n]{$passwordmin,$passwordmax})$/ ) {
1332       $recref->{_password} = $1;
1333     } else {
1334       return gettext('illegal_password'). " $passwordmin-$passwordmax ".
1335              FS::Msgcat::_gettext('illegal_password_characters');
1336     }
1337
1338     if ( $password_noampersand ) {
1339       $recref->{_password} =~ /\&/ and return gettext('illegal_password');
1340     }
1341     if ( $password_noexclamation ) {
1342       $recref->{_password} =~ /\!/ and return gettext('illegal_password');
1343     }
1344   }
1345   else {
1346     return "invalid password encoding ('".$recref->{_password_encoding}."'";
1347   }
1348
1349   $self->SUPER::check;
1350
1351 }
1352
1353
1354 sub _password_encryption {
1355   my $self = shift;
1356   my $encoding = lc($self->_password_encoding);
1357   return if !$encoding;
1358   return 'plain' if $encoding eq 'plain';
1359   if($encoding eq 'crypt') {
1360     my $pass = $self->_password;
1361     $pass =~ s/^\*SUSPENDED\* //;
1362     $pass =~ s/^!!?//;
1363     return 'md5' if $pass =~ /^\$1\$/;
1364     #return 'blowfish' if $self->_password =~ /^\$2\$/;
1365     return 'des' if length($pass) == 13;
1366     return;
1367   }
1368   if($encoding eq 'ldap') {
1369     uc($self->_password) =~ /^\{([\w-]+)\}/;
1370     return 'crypt' if $1 eq 'CRYPT' or $1 eq 'DES';
1371     return 'plain' if $1 eq 'PLAIN' or $1 eq 'CLEARTEXT';
1372     return 'md5' if $1 eq 'MD5';
1373     return 'sha1' if $1 eq 'SHA' or $1 eq 'SHA-1';
1374
1375     return;
1376   }
1377   return;
1378 }
1379
1380 sub get_cleartext_password {
1381   my $self = shift;
1382   if($self->_password_encryption eq 'plain') {
1383     if($self->_password_encoding eq 'ldap') {
1384       $self->_password =~ /\{\w+\}(.*)$/;
1385       return $1;
1386     }
1387     else {
1388       return $self->_password;
1389     }
1390   }
1391   return;
1392 }
1393
1394  
1395 =item set_password
1396
1397 Set the cleartext password for the account.  If _password_encoding is set, the 
1398 new password will be encoded according to the existing method (including 
1399 encryption mode, if it can be determined).  Otherwise, 
1400 config('default-password-encoding') is used.
1401
1402 If no password is supplied (or a zero-length password when minimum password length 
1403 is >0), one will be generated randomly.
1404
1405 =cut
1406
1407 sub set_password {
1408   my( $self, $pass ) = ( shift, shift );
1409
1410   warn "[$me] set_password (to $pass) called on $self: ". Dumper($self)
1411      if $DEBUG;
1412
1413   my $failure = gettext('illegal_password'). " $passwordmin-$passwordmax ".
1414                 FS::Msgcat::_gettext('illegal_password_characters').
1415                 ": ". $pass;
1416
1417   my( $encoding, $encryption ) = ('', '');
1418
1419   if ( $self->_password_encoding ) {
1420     $encoding = $self->_password_encoding;
1421     # identify existing encryption method, try to use it.
1422     $encryption = $self->_password_encryption;
1423     if (!$encryption) {
1424       # use the system default
1425       undef $encoding;
1426     }
1427   }
1428
1429   if ( !$encoding ) {
1430     # set encoding to system default
1431     ($encoding, $encryption) =
1432       split(/-/, lc($conf->config('default-password-encoding') || ''));
1433     $encoding ||= 'legacy';
1434     $self->_password_encoding($encoding);
1435   }
1436
1437   if ( $encoding eq 'legacy' ) {
1438
1439     # The legacy behavior from check():
1440     # If the password is blank, randomize it and set encoding to 'plain'.
1441     if(!defined($pass) or (length($pass) == 0 and $passwordmin)) {
1442       $pass = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
1443       $self->_password_encoding('plain');
1444     } else {
1445       # Prefix + valid-length password
1446       if ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
1447         $pass = $1.$3;
1448         $self->_password_encoding('plain');
1449       # Prefix + crypt string
1450       } elsif ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([\w\.\/\$\;\+]{13,64})$/ ) {
1451         $pass = $1.$3;
1452         $self->_password_encoding('crypt');
1453       # Various disabled crypt passwords
1454       } elsif ( $pass eq '*' || $pass eq '!' || $pass eq '!!' ) {
1455         $self->_password_encoding('crypt');
1456       } else {
1457         return $failure;
1458       }
1459     }
1460
1461     $self->_password($pass);
1462     return;
1463
1464   }
1465
1466   return $failure
1467     if $passwordmin && length($pass) < $passwordmin
1468     or $passwordmax && length($pass) > $passwordmax;
1469
1470   if ( $encoding eq 'crypt' ) {
1471     if ($encryption eq 'md5') {
1472       $pass = unix_md5_crypt($pass);
1473     } elsif ($encryption eq 'des') {
1474       $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
1475     }
1476
1477   } elsif ( $encoding eq 'ldap' ) {
1478     if ($encryption eq 'md5') {
1479       $pass = md5_base64($pass);
1480     } elsif ($encryption eq 'sha1') {
1481       $pass = sha1_base64($pass);
1482     } elsif ($encryption eq 'crypt') {
1483       $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
1484     }
1485     # else $encryption eq 'plain', do nothing
1486     $pass .= '=' x (4 - length($pass) % 4) #properly padded base64
1487       if $encryption eq 'md5' || $encryption eq 'sha1';
1488     $pass = '{'.uc($encryption).'}'.$pass;
1489   }
1490   # else encoding eq 'plain'
1491
1492   $self->_password($pass);
1493   return;
1494 }
1495
1496 =item _check_system
1497
1498 Internal function to check the username against the list of system usernames
1499 from the I<system_usernames> configuration value.  Returns true if the username
1500 is listed on the system username list.
1501
1502 =cut
1503
1504 sub _check_system {
1505   my $self = shift;
1506   scalar( grep { $self->username eq $_ || $self->email eq $_ }
1507                $conf->config('system_usernames')
1508         );
1509 }
1510
1511 =item _check_duplicate
1512
1513 Internal method to check for duplicates usernames, username@domain pairs and
1514 uids.
1515
1516 If the I<global_unique-username> configuration value is set to B<username> or
1517 B<username@domain>, enforces global username or username@domain uniqueness.
1518
1519 In all cases, check for duplicate uids and usernames or username@domain pairs
1520 per export and with identical I<svcpart> values.
1521
1522 =cut
1523
1524 sub _check_duplicate {
1525   my $self = shift;
1526
1527   my $global_unique = $conf->config('global_unique-username') || 'none';
1528   return '' if $global_unique eq 'disabled';
1529
1530   $self->lock_table;
1531
1532   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
1533   unless ( $part_svc ) {
1534     return 'unknown svcpart '. $self->svcpart;
1535   }
1536
1537   my @dup_user = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1538                  qsearch( 'svc_acct', { 'username' => $self->username } );
1539   return gettext('username_in_use')
1540     if $global_unique eq 'username' && @dup_user;
1541
1542   my @dup_userdomain = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1543                        qsearch( 'svc_acct', { 'username' => $self->username,
1544                                               'domsvc'   => $self->domsvc } );
1545   return gettext('username_in_use')
1546     if $global_unique eq 'username@domain' && @dup_userdomain;
1547
1548   my @dup_uid;
1549   if ( $part_svc->part_svc_column('uid')->columnflag ne 'F'
1550        && $self->username !~ /^(toor|(hyla)?fax)$/          ) {
1551     @dup_uid = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1552                qsearch( 'svc_acct', { 'uid' => $self->uid } );
1553   } else {
1554     @dup_uid = ();
1555   }
1556
1557   if ( @dup_user || @dup_userdomain || @dup_uid ) {
1558     my $exports = FS::part_export::export_info('svc_acct');
1559     my %conflict_user_svcpart;
1560     my %conflict_userdomain_svcpart = ( $self->svcpart => 'SELF', );
1561
1562     foreach my $part_export ( $part_svc->part_export ) {
1563
1564       #this will catch to the same exact export
1565       my @svcparts = map { $_->svcpart } $part_export->export_svc;
1566
1567       #this will catch to exports w/same exporthost+type ???
1568       #my @other_part_export = qsearch('part_export', {
1569       #  'machine'    => $part_export->machine,
1570       #  'exporttype' => $part_export->exporttype,
1571       #} );
1572       #foreach my $other_part_export ( @other_part_export ) {
1573       #  push @svcparts, map { $_->svcpart }
1574       #    qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
1575       #}
1576
1577       #my $nodomain = $exports->{$part_export->exporttype}{'nodomain'};
1578       #silly kludge to avoid uninitialized value errors
1579       my $nodomain = exists( $exports->{$part_export->exporttype}{'nodomain'} )
1580                      ? $exports->{$part_export->exporttype}{'nodomain'}
1581                      : '';
1582       if ( $nodomain =~ /^Y/i ) {
1583         $conflict_user_svcpart{$_} = $part_export->exportnum
1584           foreach @svcparts;
1585       } else {
1586         $conflict_userdomain_svcpart{$_} = $part_export->exportnum
1587           foreach @svcparts;
1588       }
1589     }
1590
1591     foreach my $dup_user ( @dup_user ) {
1592       my $dup_svcpart = $dup_user->cust_svc->svcpart;
1593       if ( exists($conflict_user_svcpart{$dup_svcpart}) ) {
1594         return "duplicate username ". $self->username.
1595                ": conflicts with svcnum ". $dup_user->svcnum.
1596                " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
1597       }
1598     }
1599
1600     foreach my $dup_userdomain ( @dup_userdomain ) {
1601       my $dup_svcpart = $dup_userdomain->cust_svc->svcpart;
1602       if ( exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1603         return "duplicate username\@domain ". $self->email.
1604                ": conflicts with svcnum ". $dup_userdomain->svcnum.
1605                " via exportnum ". $conflict_userdomain_svcpart{$dup_svcpart};
1606       }
1607     }
1608
1609     foreach my $dup_uid ( @dup_uid ) {
1610       my $dup_svcpart = $dup_uid->cust_svc->svcpart;
1611       if ( exists($conflict_user_svcpart{$dup_svcpart})
1612            || exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1613         return "duplicate uid ". $self->uid.
1614                ": conflicts with svcnum ". $dup_uid->svcnum.
1615                " via exportnum ".
1616                ( $conflict_user_svcpart{$dup_svcpart}
1617                  || $conflict_userdomain_svcpart{$dup_svcpart} );
1618       }
1619     }
1620
1621   }
1622
1623   return '';
1624
1625 }
1626
1627 =item radius
1628
1629 Depriciated, use radius_reply instead.
1630
1631 =cut
1632
1633 sub radius {
1634   carp "FS::svc_acct::radius depriciated, use radius_reply";
1635   $_[0]->radius_reply;
1636 }
1637
1638 =item radius_reply
1639
1640 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1641 reply attributes of this record.
1642
1643 Note that this is now the preferred method for reading RADIUS attributes - 
1644 accessing the columns directly is discouraged, as the column names are
1645 expected to change in the future.
1646
1647 =cut
1648
1649 sub radius_reply { 
1650   my $self = shift;
1651
1652   return %{ $self->{'radius_reply'} }
1653     if exists $self->{'radius_reply'};
1654
1655   my %reply =
1656     map {
1657       /^(radius_(.*))$/;
1658       my($column, $attrib) = ($1, $2);
1659       #$attrib =~ s/_/\-/g;
1660       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1661     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
1662
1663   if ( $self->slipip && $self->slipip ne '0e0' ) {
1664     $reply{$radius_ip} = $self->slipip;
1665   }
1666
1667   if ( $self->seconds !~ /^$/ ) {
1668     $reply{'Session-Timeout'} = $self->seconds;
1669   }
1670
1671   if ( $conf->exists('radius-chillispot-max') ) {
1672     #http://dev.coova.org/svn/coova-chilli/doc/dictionary.chillispot
1673
1674     #hmm.  just because sqlradius.pm says so?
1675     my %whatis = (
1676       'input'  => 'up',
1677       'output' => 'down',
1678       'total'  => 'total',
1679     );
1680
1681     foreach my $what (qw( input output total )) {
1682       my $is = $whatis{$what}.'bytes';
1683       if ( $self->$is() =~ /\d/ ) {
1684         my $big = new Math::BigInt $self->$is();
1685         $big = new Math::BigInt '0' if $big->is_neg();
1686         my $att = "Chillispot-Max-\u$what";
1687         $reply{"$att-Octets"}    = $big->copy->band(0xffffffff)->bstr;
1688         $reply{"$att-Gigawords"} = $big->copy->brsft(32)->bstr;
1689       }
1690     }
1691
1692   }
1693
1694   %reply;
1695 }
1696
1697 =item radius_check
1698
1699 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1700 check attributes of this record.
1701
1702 Note that this is now the preferred method for reading RADIUS attributes - 
1703 accessing the columns directly is discouraged, as the column names are
1704 expected to change in the future.
1705
1706 =cut
1707
1708 sub radius_check {
1709   my $self = shift;
1710
1711   return %{ $self->{'radius_check'} }
1712     if exists $self->{'radius_check'};
1713
1714   my %check = 
1715     map {
1716       /^(rc_(.*))$/;
1717       my($column, $attrib) = ($1, $2);
1718       #$attrib =~ s/_/\-/g;
1719       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1720     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table );
1721
1722
1723   my($pw_attrib, $password) = $self->radius_password;
1724   $check{$pw_attrib} = $password;
1725
1726   my $cust_svc = $self->cust_svc;
1727   if ( $cust_svc ) {
1728     my $cust_pkg = $cust_svc->cust_pkg;
1729     if ( $cust_pkg && $cust_pkg->part_pkg->is_prepaid && $cust_pkg->bill ) {
1730       $check{'Expiration'} = time2str('%B %e %Y %T', $cust_pkg->bill ); #http://lists.cistron.nl/pipermail/freeradius-users/2005-January/040184.html
1731     }
1732   } else {
1733     warn "WARNING: no cust_svc record for svc_acct.svcnum ". $self->svcnum.
1734          "; can't set Expiration\n"
1735       unless $cust_svc;
1736   }
1737
1738   %check;
1739
1740 }
1741
1742 =item radius_password 
1743
1744 Returns a key/value pair containing the RADIUS attribute name and value
1745 for the password.
1746
1747 =cut
1748
1749 sub radius_password {
1750   my $self = shift;
1751
1752   my $pw_attrib;
1753   if ( $self->_password_encoding eq 'ldap' ) {
1754     $pw_attrib = 'Password-With-Header';
1755   } elsif ( $self->_password_encoding eq 'crypt' ) {
1756     $pw_attrib = 'Crypt-Password';
1757   } elsif ( $self->_password_encoding eq 'plain' ) {
1758     $pw_attrib = $radius_password;
1759   } else {
1760     $pw_attrib = length($self->_password) <= 12
1761                    ? $radius_password
1762                    : 'Crypt-Password';
1763   }
1764
1765   ($pw_attrib, $self->_password);
1766
1767 }
1768
1769 =item snapshot
1770
1771 This method instructs the object to "snapshot" or freeze RADIUS check and
1772 reply attributes to the current values.
1773
1774 =cut
1775
1776 #bah, my english is too broken this morning
1777 #Of note is the "Expiration" attribute, which, for accounts in prepaid packages, is typically defined on-the-fly as the associated packages cust_pkg.bill.  (This is used by
1778 #the FS::cust_pkg's replace method to trigger the correct export updates when
1779 #package dates change)
1780
1781 sub snapshot {
1782   my $self = shift;
1783
1784   $self->{$_} = { $self->$_() }
1785     foreach qw( radius_reply radius_check );
1786
1787 }
1788
1789 =item forget_snapshot
1790
1791 This methos instructs the object to forget any previously snapshotted
1792 RADIUS check and reply attributes.
1793
1794 =cut
1795
1796 sub forget_snapshot {
1797   my $self = shift;
1798
1799   delete $self->{$_}
1800     foreach qw( radius_reply radius_check );
1801
1802 }
1803
1804 =item domain [ END_TIMESTAMP [ START_TIMESTAMP ] ]
1805
1806 Returns the domain associated with this account.
1807
1808 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
1809 history records.
1810
1811 =cut
1812
1813 sub domain {
1814   my $self = shift;
1815   die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
1816   my $svc_domain = $self->svc_domain(@_)
1817     or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
1818   $svc_domain->domain;
1819 }
1820
1821 =item cust_svc
1822
1823 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
1824
1825 =cut
1826
1827 #inherited from svc_Common
1828
1829 =item email [ END_TIMESTAMP [ START_TIMESTAMP ] ]
1830
1831 Returns an email address associated with the account.
1832
1833 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
1834 history records.
1835
1836 =cut
1837
1838 sub email {
1839   my $self = shift;
1840   $self->username. '@'. $self->domain(@_);
1841 }
1842
1843
1844 =item acct_snarf
1845
1846 Returns an array of FS::acct_snarf records associated with the account.
1847
1848 =cut
1849
1850 # unused as originally intended, but now by Communigate Pro "RPOP"
1851
1852 =item cgp_rpop_hashref
1853
1854 Returns an arrayref of RPOP data suitable for Communigate Pro API commands.
1855
1856 =cut
1857
1858 sub cgp_rpop_hashref {
1859   my $self = shift;
1860   { map { $_->snarfname => $_->cgp_hashref } $self->acct_snarf };
1861 }
1862
1863 =item decrement_upbytes OCTETS
1864
1865 Decrements the I<upbytes> field of this record by the given amount.  If there
1866 is an error, returns the error, otherwise returns false.
1867
1868 =cut
1869
1870 sub decrement_upbytes {
1871   shift->_op_usage('-', 'upbytes', @_);
1872 }
1873
1874 =item increment_upbytes OCTETS
1875
1876 Increments the I<upbytes> field of this record by the given amount.  If there
1877 is an error, returns the error, otherwise returns false.
1878
1879 =cut
1880
1881 sub increment_upbytes {
1882   shift->_op_usage('+', 'upbytes', @_);
1883 }
1884
1885 =item decrement_downbytes OCTETS
1886
1887 Decrements the I<downbytes> field of this record by the given amount.  If there
1888 is an error, returns the error, otherwise returns false.
1889
1890 =cut
1891
1892 sub decrement_downbytes {
1893   shift->_op_usage('-', 'downbytes', @_);
1894 }
1895
1896 =item increment_downbytes OCTETS
1897
1898 Increments the I<downbytes> field of this record by the given amount.  If there
1899 is an error, returns the error, otherwise returns false.
1900
1901 =cut
1902
1903 sub increment_downbytes {
1904   shift->_op_usage('+', 'downbytes', @_);
1905 }
1906
1907 =item decrement_totalbytes OCTETS
1908
1909 Decrements the I<totalbytes> field of this record by the given amount.  If there
1910 is an error, returns the error, otherwise returns false.
1911
1912 =cut
1913
1914 sub decrement_totalbytes {
1915   shift->_op_usage('-', 'totalbytes', @_);
1916 }
1917
1918 =item increment_totalbytes OCTETS
1919
1920 Increments the I<totalbytes> field of this record by the given amount.  If there
1921 is an error, returns the error, otherwise returns false.
1922
1923 =cut
1924
1925 sub increment_totalbytes {
1926   shift->_op_usage('+', 'totalbytes', @_);
1927 }
1928
1929 =item decrement_seconds SECONDS
1930
1931 Decrements the I<seconds> field of this record by the given amount.  If there
1932 is an error, returns the error, otherwise returns false.
1933
1934 =cut
1935
1936 sub decrement_seconds {
1937   shift->_op_usage('-', 'seconds', @_);
1938 }
1939
1940 =item increment_seconds SECONDS
1941
1942 Increments the I<seconds> field of this record by the given amount.  If there
1943 is an error, returns the error, otherwise returns false.
1944
1945 =cut
1946
1947 sub increment_seconds {
1948   shift->_op_usage('+', 'seconds', @_);
1949 }
1950
1951
1952 my %op2action = (
1953   '-' => 'suspend',
1954   '+' => 'unsuspend',
1955 );
1956 my %op2condition = (
1957   '-' => sub { my($self, $column, $amount) = @_;
1958                $self->$column - $amount <= 0;
1959              },
1960   '+' => sub { my($self, $column, $amount) = @_;
1961                ($self->$column || 0) + $amount > 0;
1962              },
1963 );
1964 my %op2warncondition = (
1965   '-' => sub { my($self, $column, $amount) = @_;
1966                my $threshold = $column . '_threshold';
1967                $self->$column - $amount <= $self->$threshold + 0;
1968              },
1969   '+' => sub { my($self, $column, $amount) = @_;
1970                ($self->$column || 0) + $amount > 0;
1971              },
1972 );
1973
1974 sub _op_usage {
1975   my( $self, $op, $column, $amount ) = @_;
1976
1977   warn "$me _op_usage called for $column on svcnum ". $self->svcnum.
1978        ' ('. $self->email. "): $op $amount\n"
1979     if $DEBUG;
1980
1981   return '' unless $amount;
1982
1983   local $SIG{HUP} = 'IGNORE';
1984   local $SIG{INT} = 'IGNORE';
1985   local $SIG{QUIT} = 'IGNORE';
1986   local $SIG{TERM} = 'IGNORE';
1987   local $SIG{TSTP} = 'IGNORE';
1988   local $SIG{PIPE} = 'IGNORE';
1989
1990   my $oldAutoCommit = $FS::UID::AutoCommit;
1991   local $FS::UID::AutoCommit = 0;
1992   my $dbh = dbh;
1993
1994   my $sql = "UPDATE svc_acct SET $column = ".
1995             " CASE WHEN $column IS NULL THEN 0 ELSE $column END ". #$column||0
1996             " $op ? WHERE svcnum = ?";
1997   warn "$me $sql\n"
1998     if $DEBUG;
1999
2000   my $sth = $dbh->prepare( $sql )
2001     or die "Error preparing $sql: ". $dbh->errstr;
2002   my $rv = $sth->execute($amount, $self->svcnum);
2003   die "Error executing $sql: ". $sth->errstr
2004     unless defined($rv);
2005   die "Can't update $column for svcnum". $self->svcnum
2006     if $rv == 0;
2007
2008   #$self->snapshot; #not necessary, we retain the old values
2009   #create an object with the updated usage values
2010   my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
2011   #call exports
2012   my $error = $new->replace($self);
2013   if ( $error ) {
2014     $dbh->rollback if $oldAutoCommit;
2015     return "Error replacing: $error";
2016   }
2017
2018   #overlimit_action eq 'cancel' handling
2019   my $cust_pkg = $self->cust_svc->cust_pkg;
2020   if ( $cust_pkg
2021        && $cust_pkg->part_pkg->option('overlimit_action', 1) eq 'cancel' 
2022        && $op eq '-' && &{$op2condition{$op}}($self, $column, $amount)
2023      )
2024   {
2025
2026     my $error = $cust_pkg->cancel; #XXX should have a reason
2027     if ( $error ) {
2028       $dbh->rollback if $oldAutoCommit;
2029       return "Error cancelling: $error";
2030     }
2031
2032     #nothing else is relevant if we're cancelling, so commit & return success
2033     warn "$me update successful; committing\n"
2034       if $DEBUG;
2035     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2036     return '';
2037
2038   }
2039
2040   my $action = $op2action{$op};
2041
2042   if ( &{$op2condition{$op}}($self, $column, $amount) &&
2043         ( $action eq 'suspend'   && !$self->overlimit 
2044        || $action eq 'unsuspend' &&  $self->overlimit ) 
2045      ) {
2046
2047     my $error = $self->_op_overlimit($action);
2048     if ( $error ) {
2049       $dbh->rollback if $oldAutoCommit;
2050       return $error;
2051     }
2052
2053   }
2054
2055   if ( $conf->exists("svc_acct-usage_$action")
2056        && &{$op2condition{$op}}($self, $column, $amount)    ) {
2057     #my $error = $self->$action();
2058     my $error = $self->cust_svc->cust_pkg->$action();
2059     # $error ||= $self->overlimit($action);
2060     if ( $error ) {
2061       $dbh->rollback if $oldAutoCommit;
2062       return "Error ${action}ing: $error";
2063     }
2064   }
2065
2066   if ($warning_msgnum && &{$op2warncondition{$op}}($self, $column, $amount)) {
2067     my $wqueue = new FS::queue {
2068       'svcnum' => $self->svcnum,
2069       'job'    => 'FS::svc_acct::reached_threshold',
2070     };
2071
2072     # x_threshold race
2073     my $error = $wqueue->insert(
2074       'svcnum' => $self->svcnum,
2075       'op'     => $op,
2076       'column' => $column
2077     );
2078     if ( $error ) {
2079       $dbh->rollback if $oldAutoCommit;
2080       return "Error queuing threshold activity: $error";
2081     }
2082   }
2083
2084   warn "$me update successful; committing\n"
2085     if $DEBUG;
2086   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2087   '';
2088
2089 }
2090
2091 sub _op_overlimit {
2092   my( $self, $action ) = @_;
2093
2094   local $SIG{HUP} = 'IGNORE';
2095   local $SIG{INT} = 'IGNORE';
2096   local $SIG{QUIT} = 'IGNORE';
2097   local $SIG{TERM} = 'IGNORE';
2098   local $SIG{TSTP} = 'IGNORE';
2099   local $SIG{PIPE} = 'IGNORE';
2100
2101   my $oldAutoCommit = $FS::UID::AutoCommit;
2102   local $FS::UID::AutoCommit = 0;
2103   my $dbh = dbh;
2104
2105   my $cust_pkg = $self->cust_svc->cust_pkg;
2106
2107   my @conf_overlimit =
2108     $cust_pkg
2109       ? $conf->config('overlimit_groups', $cust_pkg->cust_main->agentnum )
2110       : $conf->config('overlimit_groups');
2111
2112   foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
2113
2114     my @groups = scalar(@conf_overlimit) ? @conf_overlimit
2115                                          : split(' ',$part_export->option('overlimit_groups'));
2116     next unless scalar(@groups);
2117
2118     my $other = new FS::svc_acct $self->hashref;
2119     $other->usergroup(\@groups);
2120
2121     my($new,$old);
2122     if ($action eq 'suspend') {
2123       $new = $other;
2124       $old = $self;
2125     } else { # $action eq 'unsuspend'
2126       $new = $self;
2127       $old = $other;
2128     }
2129
2130     my $error = $part_export->export_replace($new, $old)
2131                 || $self->overlimit($action);
2132
2133     if ( $error ) {
2134       $dbh->rollback if $oldAutoCommit;
2135       return "Error replacing radius groups: $error";
2136     }
2137
2138   }
2139
2140   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2141   '';
2142
2143 }
2144
2145 sub set_usage {
2146   my( $self, $valueref, %options ) = @_;
2147
2148   warn "$me set_usage called for svcnum ". $self->svcnum.
2149        ' ('. $self->email. "): ".
2150        join(', ', map { "$_ => " . $valueref->{$_}} keys %$valueref) . "\n"
2151     if $DEBUG;
2152
2153   local $SIG{HUP} = 'IGNORE';
2154   local $SIG{INT} = 'IGNORE';
2155   local $SIG{QUIT} = 'IGNORE';
2156   local $SIG{TERM} = 'IGNORE';
2157   local $SIG{TSTP} = 'IGNORE';
2158   local $SIG{PIPE} = 'IGNORE';
2159
2160   local $FS::svc_Common::noexport_hack = 1;
2161   my $oldAutoCommit = $FS::UID::AutoCommit;
2162   local $FS::UID::AutoCommit = 0;
2163   my $dbh = dbh;
2164
2165   my $reset = 0;
2166   my %handyhash = ();
2167   if ( $options{null} ) { 
2168     %handyhash = ( map { ( $_ => undef, $_."_threshold" => undef ) }
2169                    qw( seconds upbytes downbytes totalbytes )
2170                  );
2171   }
2172   foreach my $field (keys %$valueref){
2173     $reset = 1 if $valueref->{$field};
2174     $self->setfield($field, $valueref->{$field});
2175     $self->setfield( $field.'_threshold',
2176                      int($self->getfield($field)
2177                          * ( $conf->exists('svc_acct-usage_threshold') 
2178                              ? 1 - $conf->config('svc_acct-usage_threshold')/100
2179                              : 0.20
2180                            )
2181                        )
2182                      );
2183     $handyhash{$field} = $self->getfield($field);
2184     $handyhash{$field.'_threshold'} = $self->getfield($field.'_threshold');
2185   }
2186   #my $error = $self->replace;   #NO! we avoid the call to ->check for
2187   #die $error if $error;         #services not explicity changed via the UI
2188
2189   my $sql = "UPDATE svc_acct SET " .
2190     join (',', map { "$_ =  ?" } (keys %handyhash) ).
2191     " WHERE svcnum = ". $self->svcnum;
2192
2193   warn "$me $sql\n"
2194     if $DEBUG;
2195
2196   if (scalar(keys %handyhash)) {
2197     my $sth = $dbh->prepare( $sql )
2198       or die "Error preparing $sql: ". $dbh->errstr;
2199     my $rv = $sth->execute(values %handyhash);
2200     die "Error executing $sql: ". $sth->errstr
2201       unless defined($rv);
2202     die "Can't update usage for svcnum ". $self->svcnum
2203       if $rv == 0;
2204   }
2205
2206   #$self->snapshot; #not necessary, we retain the old values
2207   #create an object with the updated usage values
2208   my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
2209   local($FS::Record::nowarn_identical) = 1;
2210   my $error = $new->replace($self); #call exports
2211   if ( $error ) {
2212     $dbh->rollback if $oldAutoCommit;
2213     return "Error replacing: $error";
2214   }
2215
2216   if ( $reset ) {
2217
2218     my $error = '';
2219
2220     $error = $self->_op_overlimit('unsuspend')
2221       if $self->overlimit;;
2222
2223     $error ||= $self->cust_svc->cust_pkg->unsuspend
2224       if $conf->exists("svc_acct-usage_unsuspend");
2225
2226     if ( $error ) {
2227       $dbh->rollback if $oldAutoCommit;
2228       return "Error unsuspending: $error";
2229     }
2230
2231   }
2232
2233   warn "$me update successful; committing\n"
2234     if $DEBUG;
2235   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2236   '';
2237
2238 }
2239
2240
2241 =item recharge HASHREF
2242
2243   Increments usage columns by the amount specified in HASHREF as
2244   column=>amount pairs.
2245
2246 =cut
2247
2248 sub recharge {
2249   my ($self, $vhash) = @_;
2250    
2251   if ( $DEBUG ) {
2252     warn "[$me] recharge called on $self: ". Dumper($self).
2253          "\nwith vhash: ". Dumper($vhash);
2254   }
2255
2256   my $oldAutoCommit = $FS::UID::AutoCommit;
2257   local $FS::UID::AutoCommit = 0;
2258   my $dbh = dbh;
2259   my $error = '';
2260
2261   foreach my $column (keys %$vhash){
2262     $error ||= $self->_op_usage('+', $column, $vhash->{$column});
2263   }
2264
2265   if ( $error ) {
2266     $dbh->rollback if $oldAutoCommit;
2267   }else{
2268     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2269   }
2270   return $error;
2271 }
2272
2273 =item is_rechargeable
2274
2275 Returns true if this svc_account can be "recharged" and false otherwise.
2276
2277 =cut
2278
2279 sub is_rechargable {
2280   my $self = shift;
2281   $self->seconds ne ''
2282     || $self->upbytes ne ''
2283     || $self->downbytes ne ''
2284     || $self->totalbytes ne '';
2285 }
2286
2287 =item seconds_since TIMESTAMP
2288
2289 Returns the number of seconds this account has been online since TIMESTAMP,
2290 according to the session monitor (see L<FS::Session>).
2291
2292 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2293 L<Time::Local> and L<Date::Parse> for conversion functions.
2294
2295 =cut
2296
2297 #note: POD here, implementation in FS::cust_svc
2298 sub seconds_since {
2299   my $self = shift;
2300   $self->cust_svc->seconds_since(@_);
2301 }
2302
2303 =item last_login_text 
2304
2305 Returns text describing the time of last login.
2306
2307 =cut
2308
2309 sub last_login_text {
2310   my $self = shift;
2311   $self->last_login ? ctime($self->last_login) : 'unknown';
2312 }
2313
2314 =item psearch_cdrs OPTIONS
2315
2316 Returns a paged search (L<FS::PagedSearch>) for Call Detail Records
2317 associated with this service. For svc_acct, "associated with" means that
2318 either the "src" or the "charged_party" field of the CDR matches either
2319 the "username" field of the service or the username@domain label.
2320
2321 =cut
2322
2323 sub psearch_cdrs {
2324   my($self, %options) = @_;
2325   my @fields;
2326   my %hash;
2327   my @where;
2328
2329   my $did = dbh->quote($self->username);
2330   my $diddomain = dbh->quote($self->label);
2331
2332   my $prefix = $options{'default_prefix'} || ''; #convergent.au '+61'
2333   my $prefixdid = dbh->quote($prefix . $self->username);
2334
2335   my $for_update = $options{'for_update'} ? 'FOR UPDATE' : '';
2336
2337   if ( $options{inbound} ) {
2338     # these will be selected under their DIDs
2339     push @where, "FALSE";
2340   }
2341
2342   my @orwhere;
2343   if (!$options{'disable_charged_party'}) {
2344     push @orwhere,
2345       "charged_party = $did",
2346       "charged_party = $prefixdid",
2347       "charged_party = $diddomain"
2348       ;
2349   }
2350   if (!$options{'disable_src'}) {
2351     push @orwhere,
2352       "src = $did AND charged_party IS NULL",
2353       "src = $prefixdid AND charged_party IS NULL",
2354       "src = $diddomain AND charged_party IS NULL"
2355       ;
2356   }
2357   push @where, '(' . join(' OR ', @orwhere) . ')';
2358
2359   # $options{'status'} = '' is meaningful; for the rest of them it's not
2360   if ( exists $options{'status'} ) {
2361     $hash{'freesidestatus'} = $options{'status'};
2362   }
2363   if ( $options{'cdrtypenum'} ) {
2364     $hash{'cdrtypenum'} = $options{'cdrtypenum'};
2365   }
2366   if ( $options{'calltypenum'} ) {
2367     $hash{'calltypenum'} = $options{'calltypenum'};
2368   }
2369   if ( $options{'begin'} ) {
2370     push @where, 'startdate >= '. $options{'begin'};
2371   } 
2372   if ( $options{'end'} ) {
2373     push @where, 'startdate < '.  $options{'end'};
2374   } 
2375   if ( $options{'nonzero'} ) {
2376     push @where, 'duration > 0';
2377   } 
2378
2379   my $extra_sql = join(' AND ', @where);
2380   if ($extra_sql) {
2381     if (keys %hash) {
2382       $extra_sql = " AND ".$extra_sql;
2383     } else {
2384       $extra_sql = " WHERE ".$extra_sql;
2385     }
2386   }
2387   return psearch({
2388     'select'    => '*',
2389     'table'     => 'cdr',
2390     'hashref'   => \%hash,
2391     'extra_sql' => $extra_sql,
2392     'order_by'  => "ORDER BY startdate $for_update",
2393   });
2394 }
2395
2396 =item get_cdrs (DEPRECATED)
2397
2398 Like psearch_cdrs, but returns all the L<FS::cdr> objects at once, in a 
2399 single list. Arguments are the same as for psearch_cdrs.
2400
2401 =cut
2402
2403 sub get_cdrs {
2404   my $self = shift;
2405   my $psearch = $self->psearch_cdrs(@_);
2406   qsearch ( $psearch->{query} )
2407 }
2408
2409 # sub radius_groups has moved to svc_Radius_Mixin
2410
2411 =item clone_suspended
2412
2413 Constructor used by FS::part_export::_export_suspend fallback.  Document
2414 better.
2415
2416 =cut
2417
2418 sub clone_suspended {
2419   my $self = shift;
2420   my %hash = $self->hash;
2421   $hash{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
2422   new FS::svc_acct \%hash;
2423 }
2424
2425 =item clone_kludge_unsuspend 
2426
2427 Constructor used by FS::part_export::_export_unsuspend fallback.  Document
2428 better.
2429
2430 =cut
2431
2432 sub clone_kludge_unsuspend {
2433   my $self = shift;
2434   my %hash = $self->hash;
2435   $hash{_password} = '';
2436   new FS::svc_acct \%hash;
2437 }
2438
2439 =item check_password 
2440
2441 Checks the supplied password against the (possibly encrypted) password in the
2442 database.  Returns true for a successful authentication, false for no match.
2443
2444 Currently supported encryptions are: classic DES crypt() and MD5
2445
2446 =cut
2447
2448 sub check_password {
2449   my($self, $check_password) = @_;
2450
2451   #remove old-style SUSPENDED kludge, they should be allowed to login to
2452   #self-service and pay up
2453   ( my $password = $self->_password ) =~ s/^\*SUSPENDED\* //;
2454
2455   if ( $self->_password_encoding eq 'ldap' ) {
2456
2457     $password =~ s/^{PLAIN}/{CLEARTEXT}/;
2458     my $auth = from_rfc2307 Authen::Passphrase $password;
2459     return $auth->match($check_password);
2460
2461   } elsif ( $self->_password_encoding eq 'crypt' ) {
2462
2463     my $auth = from_crypt Authen::Passphrase $self->_password;
2464     return $auth->match($check_password);
2465
2466   } elsif ( $self->_password_encoding eq 'plain' ) {
2467
2468     return $check_password eq $password;
2469
2470   } else {
2471
2472     #XXX this could be replaced with Authen::Passphrase stuff
2473
2474     if ( $password =~ /^(\*|!!?)$/ ) { #no self-service login
2475       return 0;
2476     } elsif ( length($password) < 13 ) { #plaintext
2477       $check_password eq $password;
2478     } elsif ( length($password) == 13 ) { #traditional DES crypt
2479       crypt($check_password, $password) eq $password;
2480     } elsif ( $password =~ /^\$1\$/ ) { #MD5 crypt
2481       unix_md5_crypt($check_password, $password) eq $password;
2482     } elsif ( $password =~ /^\$2a?\$/ ) { #Blowfish
2483       warn "Can't check password: Blowfish encryption not yet supported, ".
2484            "svcnum ".  $self->svcnum. "\n";
2485       0;
2486     } else {
2487       warn "Can't check password: Unrecognized encryption for svcnum ".
2488            $self->svcnum. "\n";
2489       0;
2490     }
2491
2492   }
2493
2494 }
2495
2496 =item crypt_password [ DEFAULT_ENCRYPTION_TYPE ]
2497
2498 Returns an encrypted password, either by passing through an encrypted password
2499 in the database or by encrypting a plaintext password from the database.
2500
2501 The optional DEFAULT_ENCRYPTION_TYPE parameter can be set to I<crypt> (classic
2502 UNIX DES crypt), I<md5> (md5 crypt supported by most modern Linux and BSD
2503 distrubtions), or (eventually) I<blowfish> (blowfish hashing supported by
2504 OpenBSD, SuSE, other Linux distibutions with pam_unix2, etc.).  The default
2505 encryption type is only used if the password is not already encrypted in the
2506 database.
2507
2508 =cut
2509
2510 sub crypt_password {
2511   my $self = shift;
2512
2513   if ( $self->_password_encoding eq 'ldap' ) {
2514
2515     if ( $self->_password =~ /^\{(PLAIN|CLEARTEXT)\}(.+)$/ ) {
2516       my $plain = $2;
2517
2518       #XXX this could be replaced with Authen::Passphrase stuff
2519
2520       my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2521       if ( $encryption eq 'crypt' ) {
2522         return crypt(
2523           $self->_password,
2524           $saltset[int(rand(64))].$saltset[int(rand(64))]
2525         );
2526       } elsif ( $encryption eq 'md5' ) {
2527         return unix_md5_crypt( $self->_password );
2528       } elsif ( $encryption eq 'blowfish' ) {
2529         croak "unknown encryption method $encryption";
2530       } else {
2531         croak "unknown encryption method $encryption";
2532       }
2533
2534     } elsif ( $self->_password =~ /^\{CRYPT\}(.+)$/ ) {
2535       return $1;
2536     }
2537
2538   } elsif ( $self->_password_encoding eq 'crypt' ) {
2539
2540     return $self->_password;
2541
2542   } elsif ( $self->_password_encoding eq 'plain' ) {
2543
2544     #XXX this could be replaced with Authen::Passphrase stuff
2545
2546     my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2547     if ( $encryption eq 'crypt' ) {
2548       return crypt(
2549         $self->_password,
2550         $saltset[int(rand(64))].$saltset[int(rand(64))]
2551       );
2552     } elsif ( $encryption eq 'md5' ) {
2553       return unix_md5_crypt( $self->_password );
2554     } elsif ( $encryption eq 'sha512' ) {
2555       return crypt(
2556         $self->_password,
2557         '$6$rounds=15420$'. join('', map $saltset[int(rand(64))], (1..16) )
2558       );
2559     } elsif ( $encryption eq 'sha1_base64' ) { #for acct_sql
2560       my $pass = sha1_base64( $self->_password );
2561       $pass .= '=' x (4 - length($pass) % 4); #properly padded base64
2562       return $pass;
2563     } elsif ( $encryption eq 'blowfish' ) {
2564       croak "unknown encryption method $encryption";
2565     } else {
2566       croak "unknown encryption method $encryption";
2567     }
2568
2569   } else {
2570
2571     if ( length($self->_password) == 13
2572          || $self->_password =~ /^\$(1|2a?)\$/
2573          || $self->_password =~ /^(\*|NP|\*LK\*|!!?)$/
2574        )
2575     {
2576       $self->_password;
2577     } else {
2578     
2579       #XXX this could be replaced with Authen::Passphrase stuff
2580
2581       my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2582       if ( $encryption eq 'crypt' ) {
2583         return crypt(
2584           $self->_password,
2585           $saltset[int(rand(64))].$saltset[int(rand(64))]
2586         );
2587       } elsif ( $encryption eq 'md5' ) {
2588         return unix_md5_crypt( $self->_password );
2589       } elsif ( $encryption eq 'blowfish' ) {
2590         croak "unknown encryption method $encryption";
2591       } else {
2592         croak "unknown encryption method $encryption";
2593       }
2594
2595     }
2596
2597   }
2598
2599 }
2600
2601 =item ldap_password [ DEFAULT_ENCRYPTION_TYPE ]
2602
2603 Returns an encrypted password in "LDAP" format, with a curly-bracked prefix
2604 describing the format, for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or
2605 "{MD5}5426824942db4253f87a1009fd5d2d4".
2606
2607 The optional DEFAULT_ENCRYPTION_TYPE is not yet used, but the idea is for it
2608 to work the same as the B</crypt_password> method.
2609
2610 =cut
2611
2612 sub ldap_password {
2613   my $self = shift;
2614   #eventually should check a "password-encoding" field
2615
2616   if ( $self->_password_encoding eq 'ldap' ) {
2617
2618     return $self->_password;
2619
2620   } elsif ( $self->_password_encoding eq 'crypt' ) {
2621
2622     if ( length($self->_password) == 13 ) { #crypt
2623       return '{CRYPT}'. $self->_password;
2624     } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
2625       return '{MD5}'. $1;
2626     #} elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
2627     #  die "Blowfish encryption not supported in this context, svcnum ".
2628     #      $self->svcnum. "\n";
2629     } else {
2630       warn "encryption method not (yet?) supported in LDAP context";
2631       return '{CRYPT}*'; #unsupported, should not auth
2632     }
2633
2634   } elsif ( $self->_password_encoding eq 'plain' ) {
2635
2636     return '{PLAIN}'. $self->_password;
2637
2638     #return '{CLEARTEXT}'. $self->_password; #?
2639
2640   } else {
2641
2642     if ( length($self->_password) == 13 ) { #crypt
2643       return '{CRYPT}'. $self->_password;
2644     } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
2645       return '{MD5}'. $1;
2646     } elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
2647       warn "Blowfish encryption not supported in this context, svcnum ".
2648           $self->svcnum. "\n";
2649       return '{CRYPT}*';
2650
2651     #are these two necessary anymore?
2652     } elsif ( $self->_password =~ /^(\w{48})$/ ) { #LDAP SSHA
2653       return '{SSHA}'. $1;
2654     } elsif ( $self->_password =~ /^(\w{64})$/ ) { #LDAP NS-MTA-MD5
2655       return '{NS-MTA-MD5}'. $1;
2656
2657     } else { #plaintext
2658       return '{PLAIN}'. $self->_password;
2659
2660       #return '{CLEARTEXT}'. $self->_password; #?
2661       
2662       #XXX this could be replaced with Authen::Passphrase stuff if it gets used
2663       #my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2664       #if ( $encryption eq 'crypt' ) {
2665       #  return '{CRYPT}'. crypt(
2666       #    $self->_password,
2667       #    $saltset[int(rand(64))].$saltset[int(rand(64))]
2668       #  );
2669       #} elsif ( $encryption eq 'md5' ) {
2670       #  unix_md5_crypt( $self->_password );
2671       #} elsif ( $encryption eq 'blowfish' ) {
2672       #  croak "unknown encryption method $encryption";
2673       #} else {
2674       #  croak "unknown encryption method $encryption";
2675       #}
2676     }
2677
2678   }
2679
2680 }
2681
2682 =item domain_slash_username
2683
2684 Returns $domain/$username/
2685
2686 =cut
2687
2688 sub domain_slash_username {
2689   my $self = shift;
2690   $self->domain. '/'. $self->username. '/';
2691 }
2692
2693 =item virtual_maildir
2694
2695 Returns $domain/maildirs/$username/
2696
2697 =cut
2698
2699 sub virtual_maildir {
2700   my $self = shift;
2701   $self->domain. '/maildirs/'. $self->username. '/';
2702 }
2703
2704 =item password_svc_check
2705
2706 Override, for L<FS::Password_Mixin>.  Not really intended for other use.
2707
2708 =cut
2709
2710 sub password_svc_check {
2711   my ($self, $password) = @_;
2712   foreach my $field ( qw(username finger) ) {
2713     foreach my $word (split(/\W+/,$self->get($field))) {
2714       next unless length($word) > 2;
2715       if ($password =~ /$word/i) {
2716         return qq(Password contains account information '$word');
2717       }
2718     }
2719   }
2720   return '';
2721 }
2722
2723 =back
2724
2725 =head1 CLASS METHODS
2726
2727 =over 4
2728
2729 =item search HASHREF
2730
2731 Class method which returns a qsearch hash expression to search for parameters
2732 specified in HASHREF.  Valid parameters are
2733
2734 =over 4
2735
2736 =item domain
2737
2738 =item domsvc
2739
2740 =item unlinked
2741
2742 =item agentnum
2743
2744 =item pkgpart
2745
2746 Arrayref of pkgparts
2747
2748 =item pkgpart
2749
2750 =item where
2751
2752 Arrayref of additional WHERE clauses, will be ANDed together.
2753
2754 =item order_by
2755
2756 =item cust_fields
2757
2758 =back
2759
2760 =cut
2761
2762 sub _search_svc {
2763   my( $class, $params, $from, $where ) = @_;
2764
2765   #these two should probably move to svc_Domain_Mixin ?
2766
2767   # domain
2768   if ( $params->{'domain'} ) { 
2769     my $svc_domain = qsearchs('svc_domain', { 'domain'=>$params->{'domain'} } );
2770     #preserve previous behavior & bubble up an error if $svc_domain not found?
2771     push @$where, 'domsvc = '. $svc_domain->svcnum if $svc_domain;
2772   }
2773
2774   # domsvc
2775   if ( $params->{'domsvc'} =~ /^(\d+)$/ ) { 
2776     push @$where, "domsvc = $1";
2777   }
2778
2779
2780   # popnum
2781   if ( $params->{'popnum'} =~ /^(\d+)$/ ) { 
2782     push @$where, "popnum = $1";
2783   }
2784
2785
2786   #and these in svc_Tower_Mixin, or maybe we never should have done svc_acct
2787   # towers (or, as mark thought, never should have done svc_broadband)
2788
2789   # sector and tower
2790   my @where_sector = $class->tower_sector_sql($params);
2791   if ( @where_sector ) {
2792     push @$where, @where_sector;
2793     push @$from, ' LEFT JOIN tower_sector USING ( sectornum )';
2794   }
2795
2796 }
2797
2798 =back
2799
2800 =head1 SUBROUTINES
2801
2802 =over 4
2803
2804 =item check_and_rebuild_fuzzyfiles
2805
2806 =cut
2807
2808 sub check_and_rebuild_fuzzyfiles {
2809   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2810   -e "$dir/svc_acct.username"
2811     or &rebuild_fuzzyfiles;
2812 }
2813
2814 =item rebuild_fuzzyfiles
2815
2816 =cut
2817
2818 sub rebuild_fuzzyfiles {
2819
2820   use Fcntl qw(:flock);
2821
2822   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2823
2824   #username
2825
2826   open(USERNAMELOCK,">>$dir/svc_acct.username")
2827     or die "can't open $dir/svc_acct.username: $!";
2828   flock(USERNAMELOCK,LOCK_EX)
2829     or die "can't lock $dir/svc_acct.username: $!";
2830
2831   my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
2832
2833   open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
2834     or die "can't open $dir/svc_acct.username.tmp: $!";
2835   print USERNAMECACHE join("\n", @all_username), "\n";
2836   close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
2837
2838   rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
2839   close USERNAMELOCK;
2840
2841 }
2842
2843 =item all_username
2844
2845 =cut
2846
2847 sub all_username {
2848   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2849   open(USERNAMECACHE,"<$dir/svc_acct.username")
2850     or die "can't open $dir/svc_acct.username: $!";
2851   my @array = map { chomp; $_; } <USERNAMECACHE>;
2852   close USERNAMECACHE;
2853   \@array;
2854 }
2855
2856 =item append_fuzzyfiles USERNAME
2857
2858 =cut
2859
2860 sub append_fuzzyfiles {
2861   my $username = shift;
2862
2863   &check_and_rebuild_fuzzyfiles;
2864
2865   use Fcntl qw(:flock);
2866
2867   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2868
2869   open(USERNAME,">>$dir/svc_acct.username")
2870     or die "can't open $dir/svc_acct.username: $!";
2871   flock(USERNAME,LOCK_EX)
2872     or die "can't lock $dir/svc_acct.username: $!";
2873
2874   print USERNAME "$username\n";
2875
2876   flock(USERNAME,LOCK_UN)
2877     or die "can't unlock $dir/svc_acct.username: $!";
2878   close USERNAME;
2879
2880   1;
2881 }
2882
2883
2884 =item reached_threshold
2885
2886 Performs some activities when svc_acct thresholds (such as number of seconds
2887 remaining) are reached.  
2888
2889 =cut
2890
2891 sub reached_threshold {
2892   my %opt = @_;
2893
2894   my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $opt{'svcnum'} } );
2895   die "Cannot find svc_acct with svcnum " . $opt{'svcnum'} unless $svc_acct;
2896
2897   if ( $opt{'op'} eq '+' ){
2898     $svc_acct->setfield( $opt{'column'}.'_threshold',
2899                          int($svc_acct->getfield($opt{'column'})
2900                              * ( $conf->exists('svc_acct-usage_threshold') 
2901                                  ? $conf->config('svc_acct-usage_threshold')/100
2902                                  : 0.80
2903                                )
2904                          )
2905                        );
2906     my $error = $svc_acct->replace;
2907     die $error if $error;
2908   }elsif ( $opt{'op'} eq '-' ){
2909     
2910     my $threshold = $svc_acct->getfield( $opt{'column'}.'_threshold' );
2911     return '' if ($threshold eq '' );
2912
2913     $svc_acct->setfield( $opt{'column'}.'_threshold', 0 );
2914     my $error = $svc_acct->replace;
2915     die $error if $error; # email next time, i guess
2916
2917     if ( $warning_msgnum ) {
2918
2919       my $msg_template = qsearchs('msg_template',{ msgnum => $warning_msgnum });
2920       die "Could not load template for threshold_warning_msgnum ($warning_msgnum)" unless $msg_template;
2921
2922       my $cust_main = $svc_acct->cust_svc->cust_pkg->cust_main;
2923
2924       my $to = join(', ', $cust_main->invoicing_list_emailonly );
2925
2926       my $error = $msg_template->send(
2927         cust_main     => $cust_main,
2928         object        => $svc_acct,
2929         to            => $to,
2930         substitutions => {
2931           # have to override these, because we changed threshold above
2932           'column'    => $opt{'column'},
2933           'amount'    => $opt{'column'} =~/bytes/
2934                          ? FS::UI::bytecount::display_bytecount($svc_acct->getfield($opt{'column'}))
2935                          : $svc_acct->getfield($opt{'column'}),
2936           'threshold' => $opt{'column'} =~/bytes/
2937                          ? FS::UI::bytecount::display_bytecount($threshold)
2938                          : $threshold,
2939         },
2940       );
2941
2942       die "Error sending threshold warning email: $error" if $error;
2943
2944     }
2945   }else{
2946     die "unknown op: " . $opt{'op'};
2947   }
2948 }
2949
2950 =back
2951
2952 =head1 BUGS
2953
2954 The $recref stuff in sub check should be cleaned up.
2955
2956 The suspend, unsuspend and cancel methods update the database, but not the
2957 current object.  This is probably a bug as it's unexpected and
2958 counterintuitive.
2959
2960 insertion of RADIUS group stuff in insert could be done with child_objects now
2961 (would probably clean up export of them too)
2962
2963 _op_usage and set_usage bypass the history... maybe they shouldn't
2964
2965 =head1 SEE ALSO
2966
2967 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
2968 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
2969 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
2970 L<freeside-queued>), L<FS::svc_acct_pop>,
2971 schema.html from the base documentation.
2972
2973 =cut
2974
2975 1;