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