oops, that's better
[freeside.git] / FS / FS / svc_acct.pm
index 1e1cbb0..28ef949 100644 (file)
 package FS::svc_acct;
 
 use strict;
 package FS::svc_acct;
 
 use strict;
-use vars qw( @ISA $nossh_hack $noexport_hack $conf
+use vars qw( @ISA $DEBUG $me $conf
              $dir_prefix @shells $usernamemin
              $usernamemax $passwordmin $passwordmax
              $username_ampersand $username_letter $username_letterfirst
              $dir_prefix @shells $usernamemin
              $usernamemax $passwordmin $passwordmax
              $username_ampersand $username_letter $username_letterfirst
-             $username_noperiod $username_uppercase
-             $shellmachine $useradd $usermod $userdel $mydomain
-             $cyrus_server $cyrus_admin_user $cyrus_admin_pass
+             $username_noperiod $username_nounderscore $username_nodash
+             $username_uppercase
+             $welcome_template $welcome_from $welcome_subject $welcome_mimetype
+             $smtpmachine
+             $radius_password $radius_ip
              $dirhash
              $dirhash
-             @saltset @pw_set
-             $rsync $ssh $exportdir $vpopdir);
+             @saltset @pw_set );
 use Carp;
 use Carp;
-use File::Path;
 use Fcntl qw(:flock);
 use Fcntl qw(:flock);
+use Crypt::PasswdMD5;
 use FS::UID qw( datasrc );
 use FS::Conf;
 use FS::UID qw( datasrc );
 use FS::Conf;
-use FS::Record qw( qsearch qsearchs fields dbh );
+use FS::Record qw( qsearch qsearchs fields dbh dbdef );
 use FS::svc_Common;
 use FS::svc_Common;
-use Net::SSH;
+use FS::cust_svc;
 use FS::part_svc;
 use FS::svc_acct_pop;
 use FS::part_svc;
 use FS::svc_acct_pop;
-use FS::svc_acct_sm;
 use FS::cust_main_invoice;
 use FS::svc_domain;
 use FS::raddb;
 use FS::queue;
 use FS::radius_usergroup;
 use FS::cust_main_invoice;
 use FS::svc_domain;
 use FS::raddb;
 use FS::queue;
 use FS::radius_usergroup;
+use FS::export_svc;
+use FS::part_export;
 use FS::Msgcat qw(gettext);
 use FS::Msgcat qw(gettext);
+use FS::svc_forward;
+use FS::svc_www;
 
 @ISA = qw( FS::svc_Common );
 
 
 @ISA = qw( FS::svc_Common );
 
+$DEBUG = 0;
+#$DEBUG = 1;
+$me = '[FS::svc_acct]';
+
 #ask FS::UID to run this stuff for us later
 $FS::UID::callback{'FS::svc_acct'} = sub { 
 #ask FS::UID to run this stuff for us later
 $FS::UID::callback{'FS::svc_acct'} = sub { 
-  $rsync = "rsync";
-  $ssh = "ssh";
   $conf = new FS::Conf;
   $dir_prefix = $conf->config('home');
   @shells = $conf->config('shells');
   $conf = new FS::Conf;
   $dir_prefix = $conf->config('home');
   @shells = $conf->config('shells');
-  $shellmachine = $conf->config('shellmachine');
   $usernamemin = $conf->config('usernamemin') || 2;
   $usernamemax = $conf->config('usernamemax');
   $passwordmin = $conf->config('passwordmin') || 6;
   $passwordmax = $conf->config('passwordmax') || 8;
   $usernamemin = $conf->config('usernamemin') || 2;
   $usernamemax = $conf->config('usernamemax');
   $passwordmin = $conf->config('passwordmin') || 6;
   $passwordmax = $conf->config('passwordmax') || 8;
-  if ( $shellmachine ) {
-    if ( $conf->exists('shellmachine-useradd') ) {
-      $useradd = join("\n", $conf->config('shellmachine-useradd') )
-                 || 'cp -pr /etc/skel $dir; chown -R $uid.$gid $dir';
-    } else {
-      $useradd = 'useradd -d $dir -m -s $shell -u $uid $username';
-    }
-    if ( $conf->exists('shellmachine-userdel') ) {
-      $userdel = join("\n", $conf->config('shellmachine-userdel') )
-                 || 'rm -rf $dir';
-    } else {
-      $userdel = 'userdel $username';
-    }
-    $usermod = join("\n", $conf->config('shellmachine-usermod') )
-               || '[ -d $old_dir ] && mv $old_dir $new_dir || ( '.
-                    'chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; '.
-                    'find . -depth -print | cpio -pdm $new_dir; '.
-                    'chmod u-t $new_dir; chown -R $uid.$gid $new_dir; '.
-                    'rm -rf $old_dir'.
-                  ')';
-  }
   $username_letter = $conf->exists('username-letter');
   $username_letterfirst = $conf->exists('username-letterfirst');
   $username_noperiod = $conf->exists('username-noperiod');
   $username_letter = $conf->exists('username-letter');
   $username_letterfirst = $conf->exists('username-letterfirst');
   $username_noperiod = $conf->exists('username-noperiod');
+  $username_nounderscore = $conf->exists('username-nounderscore');
+  $username_nodash = $conf->exists('username-nodash');
   $username_uppercase = $conf->exists('username-uppercase');
   $username_ampersand = $conf->exists('username-ampersand');
   $username_uppercase = $conf->exists('username-uppercase');
   $username_ampersand = $conf->exists('username-ampersand');
-  $mydomain = $conf->config('domain');
-  if ( $conf->exists('cyrus') ) {
-    ($cyrus_server, $cyrus_admin_user, $cyrus_admin_pass) =
-      $conf->config('cyrus');
-    eval "use Cyrus::IMAP::Admin;"
-  } else {
-    $cyrus_server = '';
-    $cyrus_admin_user = '';
-    $cyrus_admin_pass = '';
-  }
-
   $dirhash = $conf->config('dirhash') || 0;
   $dirhash = $conf->config('dirhash') || 0;
-  $exportdir = "/usr/local/etc/freeside/export." . datasrc;
-  if ( $conf->exists('vpopmailmachines') ) {
-    my (@vpopmailmachines) = $conf->config('vpopmailmachines');
-    my ($machine, $dir, $uid, $gid) = split (/\s+/, $vpopmailmachines[0]);
-    $vpopdir = $dir;
+  if ( $conf->exists('welcome_email') ) {
+    $welcome_template = new Text::Template (
+      TYPE   => 'ARRAY',
+      SOURCE => [ map "$_\n", $conf->config('welcome_email') ]
+    ) or warn "can't create welcome email template: $Text::Template::ERROR";
+    $welcome_from = $conf->config('welcome_email-from'); # || 'your-isp-is-dum'
+    $welcome_subject = $conf->config('welcome_email-subject') || 'Welcome';
+    $welcome_mimetype = $conf->config('welcome_email-mimetype') || 'text/plain';
   } else {
   } else {
-    $vpopdir = '';
+    $welcome_template = '';
+    $welcome_from = '';
+    $welcome_subject = '';
+    $welcome_mimetype = '';
   }
   }
+  $smtpmachine = $conf->config('smtpmachine');
+  $radius_password = $conf->config('radius-password') || 'Password';
+  $radius_ip = $conf->config('radius-ip') || 'Framed-IP-Address';
 };
 
 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
 
 };
 
 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
 
-#not needed in 5.004 #srand($$|time);
-
 sub _cache {
   my $self = shift;
   my ( $hashref, $cache ) = @_;
 sub _cache {
   my $self = shift;
   my ( $hashref, $cache ) = @_;
@@ -198,7 +180,7 @@ Creates a new account.  To add the account to the database, see L<"insert">.
 
 sub table { 'svc_acct'; }
 
 
 sub table { 'svc_acct'; }
 
-=item insert
+=item insert [ , OPTION => VALUE ... ]
 
 Adds this account to the database.  If there is an error, returns the error,
 otherwise returns false.
 
 Adds this account to the database.  If there is an error, returns the error,
 otherwise returns false.
@@ -207,35 +189,28 @@ The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be
 defined.  An FS::cust_svc record will be created and inserted.
 
 The additional field I<usergroup> can optionally be defined; if so it should
 defined.  An FS::cust_svc record will be created and inserted.
 
 The additional field I<usergroup> can optionally be defined; if so it should
-contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
-sqlradius export only)
+contain an arrayref of group names.  See L<FS::radius_usergroup>.
 
 
-If the configuration value (see L<FS::Conf>) shellmachine exists, and the 
-username, uid, and dir fields are defined, the command(s) specified in
-the shellmachine-useradd configuration are added to the job queue (see
-L<FS::queue> and L<freeside-queued>) to be exectued on shellmachine via ssh.
-This behaviour can be surpressed by setting $FS::svc_acct::nossh_hack true.
-If the shellmachine-useradd configuration file does not exist,
+The additional field I<child_objects> can optionally be defined; if so it
+should contain an arrayref of FS::tablename objects.  They will have their
+svcnum fields set and will be inserted after this record, but before any
+exports are run.
 
 
-  useradd -d $dir -m -s $shell -u $uid $username
+Currently available options are: I<depend_jobnum>
 
 
-is the default.  If the shellmachine-useradd configuration file exists but
-it empty,
+If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
+jobnums), all provisioning jobs will have a dependancy on the supplied
+jobnum(s) (they will not run until the specific job(s) complete(s)).
 
 
-  cp -pr /etc/skel $dir; chown -R $uid.$gid $dir
+(TODOC: L<FS::queue> and L<freeside-queued>)
 
 
-is the default instead.  Otherwise the contents of the file are treated as
-a double-quoted perl string, with the following variables available:
-$username, $uid, $gid, $dir, and $shell.
-
-(TODOC: cyrus config file, L<FS::queue> and L<freeside-queued>)
-
-(TODOC: new exports! $noexport_hack)
+(TODOC: new exports!)
 
 =cut
 
 sub insert {
   my $self = shift;
 
 =cut
 
 sub insert {
   my $self = shift;
+  my %options = @_;
   my $error;
 
   local $SIG{HUP} = 'IGNORE';
   my $error;
 
   local $SIG{HUP} = 'IGNORE';
@@ -252,12 +227,7 @@ sub insert {
   $error = $self->check;
   return $error if $error;
 
   $error = $self->check;
   return $error if $error;
 
-  return gettext('username_in_use'). ": ". $self->username
-    if qsearchs( 'svc_acct', { 'username' => $self->username,
-                               'domsvc'   => $self->domsvc,
-                             } );
-
-  if ( $self->svcnum ) {
+  if ( $self->svcnum && qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) ) {
     my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
     unless ( $cust_svc ) {
       $dbh->rollback if $oldAutoCommit;
     my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
     unless ( $cust_svc ) {
       $dbh->rollback if $oldAutoCommit;
@@ -267,15 +237,18 @@ sub insert {
     $self->svcpart($cust_svc->svcpart);
   }
 
     $self->svcpart($cust_svc->svcpart);
   }
 
-  my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
-  return "Unknown svcpart" unless $part_svc;
-  return "uid in use"
-    if $part_svc->part_svc_column('uid')->columnflag ne 'F'
-      && qsearchs( 'svc_acct', { 'uid' => $self->uid } )
-      && $self->username !~ /^(hyla)?fax$/
-    ;
+  $error = $self->_check_duplicate;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
 
 
-  $error = $self->SUPER::insert;
+  my @jobnums;
+  $error = $self->SUPER::insert(
+    'jobnums'       => \@jobnums,
+    'child_objects' => $self->child_objects,
+    %options,
+  );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -295,152 +268,88 @@ sub insert {
     }
   }
 
     }
   }
 
-  #new-style exports!
-  unless ( $noexport_hack ) {
-    foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_insert($self);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "exporting to ". $part_export->exporttype.
-               " (transaction rolled back): $error";
-      }
-    }
-  }
-
-  #old-style exports
-
-  my( $username, $uid, $gid, $dir, $shell ) = (
-    $self->username,
-    $self->uid,
-    $self->gid,
-    $self->dir,
-    $self->shell,
-  );
-  if ( $username && $uid && $dir && $shellmachine && ! $nossh_hack ) {
-    my $queue = new FS::queue {
-      'svcnum' => $self->svcnum,
-      'job' => 'Net::SSH::ssh_cmd',
-    };
-    $error = $queue->insert("root\@$shellmachine", eval qq("$useradd") );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
+  #false laziness with sub replace (and cust_main)
+  my $queue = new FS::queue {
+    'svcnum' => $self->svcnum,
+    'job'    => 'FS::svc_acct::append_fuzzyfiles'
+  };
+  $error = $queue->insert($self->username);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "queueing job (transaction rolled back): $error";
   }
 
   }
 
-  if ( $cyrus_server ) {
-    my $queue = new FS::queue {
-      'svcnum' => $self->svcnum,
-      'job'    => 'FS::svc_acct::cyrus_insert',
-    };
-    $error = $queue->insert($self->username, $self->quota);
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
-  }
+  my $cust_pkg = $self->cust_svc->cust_pkg;
 
 
-  if ( $vpopdir ) {
+  if ( $cust_pkg ) {
+    my $cust_main = $cust_pkg->cust_main;
 
 
-    my $vpopmail_queue =
-      new FS::queue { 
-      'svcnum' => $self->svcnum,
-      'job' => 'FS::svc_acct::vpopmail_insert'
-    };
-    $error = $vpopmail_queue->insert( $self->username,
-      crypt($self->_password,$saltset[int(rand(64))].$saltset[int(rand(64))]),
-                                      $self->domain,
-                                      $vpopdir,
-                                    );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
+    if ( $conf->exists('emailinvoiceauto') ) {
+      my @invoicing_list = $cust_main->invoicing_list;
+      push @invoicing_list, $self->email;
+      $cust_main->invoicing_list(\@invoicing_list);
     }
 
     }
 
-  }
-
-  #end of old-style exports
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
-}
-
-sub cyrus_insert {
-  my( $username, $quota ) = @_;
-
-  warn "cyrus_insert: starting for user $username, quota $quota\n";
-
-  warn "cyrus_insert: connecting to $cyrus_server\n";
-  my $client = Cyrus::IMAP::Admin->new($cyrus_server);
-
-  warn "cyrus_insert: authentication as $cyrus_admin_user\n";
-  $client->authenticate(
-    -user      => $cyrus_admin_user,
-    -mechanism => "login",       
-    -password  => $cyrus_admin_pass
-  );
-
-  warn "cyrus_insert: creating user.$username\n";
-  my $rc = $client->create("user.$username");
-  my $error = $client->error;
-  die "cyrus_insert: error creating user.$username: $error" if $error;
-
-  warn "cyrus_insert: setacl user.$username, $username => all\n";
-  $rc = $client->setacl("user.$username", $username => 'all' );
-  $error = $client->error;
-  die "cyrus_insert: error setacl user.$username: $error" if $error;
-
-  if ( $quota ) {
-    warn "cyrus_insert: setquota user.$username, STORAGE => $quota\n";
-    $rc = $client->setquota("user.$username", 'STORAGE' => $quota );
-    $error = $client->error;
-    die "cyrus_insert: error setquota user.$username: $error" if $error;
-  }
+    #welcome email
+    my $to = '';
+    if ( $welcome_template && $cust_pkg ) {
+      my $to = join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list );
+      if ( $to ) {
+        my $wqueue = new FS::queue {
+          'svcnum' => $self->svcnum,
+          'job'    => 'FS::svc_acct::send_email'
+        };
+        my $error = $wqueue->insert(
+          'to'       => $to,
+          'from'     => $welcome_from,
+          'subject'  => $welcome_subject,
+          'mimetype' => $welcome_mimetype,
+          'body'     => $welcome_template->fill_in( HASH => {
+                          'custnum'  => $self->custnum,
+                          'username' => $self->username,
+                          'password' => $self->_password,
+                          'first'    => $cust_main->first,
+                          'last'     => $cust_main->getfield('last'),
+                          'pkg'      => $cust_pkg->part_pkg->pkg,
+                        } ),
+        );
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "error queuing welcome email: $error";
+        }
 
 
-  1;
-}
+        if ( $options{'depend_jobnum'} ) {
+          warn "$me depend_jobnum found; adding to welcome email dependancies"
+            if $DEBUG;
+          if ( ref($options{'depend_jobnum'}) ) {
+            warn "$me adding jobs ". join(', ', @{$options{'depend_jobnum'}} ).
+                 "to welcome email dependancies"
+              if $DEBUG;
+            push @jobnums, @{ $options{'depend_jobnum'} };
+          } else {
+            warn "$me adding job $options{'depend_jobnum'} ".
+                 "to welcome email dependancies"
+              if $DEBUG;
+            push @jobnums, $options{'depend_jobnum'};
+          }
+        }
 
 
-sub vpopmail_insert {
-  my( $username, $password, $domain, $vpopdir ) = @_;
-  
-  (open(VPASSWD, ">>$exportdir/domains/$domain/vpasswd")
-    and flock(VPASSWD,LOCK_EX)
-  ) or die "can't open vpasswd file for $username\@$domain: $exportdir/domains/$domain/vpasswd";
-  print VPASSWD join(":",
-    $username,
-    $password,
-    '1',
-    '0',
-    $username,
-    "$vpopdir/domains/$domain/$username",
-    'NOQUOTA',
-  ), "\n";
-
-  flock(VPASSWD,LOCK_UN);
-  close(VPASSWD);
-
-  mkdir "$exportdir/domains/$domain/$username", 0700  or die "can't create Maildir";
-  mkdir "$exportdir/domains/$domain/$username/Maildir", 0700 or die "can't create Maildir";
-  mkdir "$exportdir/domains/$domain/$username/Maildir/cur", 0700 or die "can't create Maildir";
-  mkdir "$exportdir/domains/$domain/$username/Maildir/new", 0700 or die "can't create Maildir";
-  mkdir "$exportdir/domains/$domain/$username/Maildir/tmp", 0700 or die "can't create Maildir";
-  my $queue = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_sync' };
-  my $error = $queue->insert;
-  die $error if $error;
+        foreach my $jobnum ( @jobnums ) {
+          my $error = $wqueue->depend_insert($jobnum);
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "error queuing welcome email job dependancy: $error";
+          }
+        }
 
 
-  1;
-}
+      }
 
 
-sub vpopmail_sync {
+    }
 
 
-  my (@vpopmailmachines) = $conf->config('vpopmailmachines');
-  my ($machine, $dir, $uid, $gid) = split (/\s+/, $vpopmailmachines[0]);
-  
-  chdir $exportdir;
-  my @args = ("$rsync", "-rlpt", "-e", "$ssh", "domains/", "vpopmail\@$machine:$vpopdir/domains/");
-  system {$args[0]} @args;
+  } # if ( $cust_pkg )
 
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
 }
 
 =item delete
 }
 
 =item delete
@@ -450,37 +359,14 @@ error, otherwise returns false.
 
 The corresponding FS::cust_svc record will be deleted as well.
 
 
 The corresponding FS::cust_svc record will be deleted as well.
 
-If the configuration value (see L<FS::Conf>) shellmachine exists, the
-command(s) specified in the shellmachine-userdel configuration file are
-added to the job queue (see L<FS::queue> and L<freeside-queued>) to be executed
-on shellmachine via ssh.  This behavior can be surpressed by setting
-$FS::svc_acct::nossh_hack true.  If the shellmachine-userdel configuration
-file does not exist,
-
-  userdel $username
-
-is the default.  If the shellmachine-userdel configuration file exists but
-is empty,
-
-  rm -rf $dir
-
-is the default instead.  Otherwise the contents of the file are treated as a
-double-quoted perl string, with the following variables available:
-$username and $dir.
-
-(TODOC: cyrus config file)
-
-(TODOC: new exports! $noexport_hack)
+(TODOC: new exports!)
 
 =cut
 
 sub delete {
   my $self = shift;
 
 
 =cut
 
 sub delete {
   my $self = shift;
 
-  if ( defined( $FS::Record::dbdef->table('svc_acct_sm') ) ) {
-    return "Can't delete an account which has (svc_acct_sm) mail aliases!"
-      if $self->uid && qsearch( 'svc_acct_sm', { 'domuid' => $self->uid } );
-  }
+  return "can't delete system account" if $self->_check_system;
 
   return "Can't delete an account which is a (svc_forward) source!"
     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
 
   return "Can't delete an account which is a (svc_forward) source!"
     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
@@ -489,7 +375,7 @@ sub delete {
     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
 
   return "Can't delete an account with (svc_www) web service!"
     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
 
   return "Can't delete an account with (svc_www) web service!"
-    if qsearch( 'svc_www', { 'usersvc' => $self->usersvc } );
+    if qsearch( 'svc_www', { 'usersvc' => $self->svcnum } );
 
   # what about records in session ? (they should refer to history table)
 
 
   # what about records in session ? (they should refer to history table)
 
@@ -544,149 +430,33 @@ sub delete {
     }
   }
 
     }
   }
 
-  my $part_svc = $self->cust_svc->part_svc;
-
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
-  #new-style exports!
-  unless ( $noexport_hack ) {
-    foreach my $part_export ( $part_svc->part_export ) {
-      my $error = $part_export->export_delete($self);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "exporting to ". $part_export->exporttype.
-               " (transaction rolled back): $error";
-      }
-    }
-  }
-
-  #old-style exports
-
-  my( $username, $dir ) = (
-    $self->username,
-    $self->dir,
-  );
-  if ( $username && $shellmachine && ! $nossh_hack ) {
-    my $queue = new FS::queue { 'job' => 'Net::SSH::ssh_cmd' };
-    $error = $queue->insert("root\@$shellmachine", eval qq("$userdel") );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
-
-  }
-
-  if ( $cyrus_server ) {
-    my $queue = new FS::queue { 'job' => 'FS::svc_acct::cyrus_delete' };
-    $error = $queue->insert($self->username);
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
-  }
-  
-  if ( $vpopdir ) {
-    my $queue = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_delete' };
-    $error = $queue->insert( $self->username, $self->domain );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
-
-  }
-
-  #end of old-style exports
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
 
-sub cyrus_delete {
-  my $username = shift; 
-
-  my $client = Cyrus::IMAP::Admin->new($cyrus_server);
-  $client->authenticate(
-    -user      => $cyrus_admin_user,
-    -mechanism => "login",       
-    -password  => $cyrus_admin_pass
-  );
-
-  my $rc = $client->setacl("user.$username", $cyrus_admin_user => 'all' );
-  my $error = $client->error;
-  die $error if $error;
-
-  $rc = $client->delete("user.$username");
-  $error = $client->error;
-  die $error if $error;
-
-  1;
-}
-
-sub vpopmail_delete {
-  my( $username, $domain ) = @_;
-  
-  (open(VPASSWD, "$exportdir/domains/$domain/vpasswd")
-    and flock(VPASSWD,LOCK_EX)
-  ) or die "can't open $exportdir/domains/$domain/vpasswd: $!";
-
-  open(VPASSWDTMP, ">$exportdir/domains/$domain/vpasswd.tmp")
-    or die "Can't open $exportdir/domains/$domain/vpasswd.tmp: $!";
-
-  while (<VPASSWD>) {
-    my ($mailbox, $rest) = split(':', $_);
-    print VPASSWDTMP $_ unless $username eq $mailbox;
-  }
-
-  close(VPASSWDTMP);
-
-  rename "$exportdir/domains/$domain/vpasswd.tmp", "$exportdir/domains/$domain/vpasswd"
-    or die "Can't rename $exportdir/domains/$domain/vpasswd.tmp: $!";
-
-  flock(VPASSWD,LOCK_UN);
-  close(VPASSWD);
-
-  rmtree "$exportdir/domains/$domain/$username" or die "can't destroy Maildir";+ 
-  1;
-}
-
 =item replace OLD_RECORD
 
 Replaces OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 The additional field I<usergroup> can optionally be defined; if so it should
 =item replace OLD_RECORD
 
 Replaces OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 The additional field I<usergroup> can optionally be defined; if so it should
-contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
-sqlradius export only)
-
-If the configuration value (see L<FS::Conf>) shellmachine exists, and the 
-dir field has changed, the command(s) specified in the shellmachine-usermod
-configuraiton file are added to the job queue (see L<FS::queue> and
-L<freeside-queued>) to be executed on shellmachine via ssh.  This behavior can
-be surpressed by setting $FS::svc-acct::nossh_hack true.  If the
-shellmachine-userdel configuration file does not exist or is empty,
-
-  [ -d $old_dir ] && mv $old_dir $new_dir || (
-    chmod u+t $old_dir;
-    mkdir $new_dir;
-    cd $old_dir;
-    find . -depth -print | cpio -pdm $new_dir;
-    chmod u-t $new_dir;
-    chown -R $uid.$gid $new_dir;
-    rm -rf $old_dir
-  )
-
-is the default.  This behaviour can be surpressed by setting
-$FS::svc_acct::nossh_hack true.
+contain an arrayref of group names.  See L<FS::radius_usergroup>.
+
 
 =cut
 
 sub replace {
   my ( $new, $old ) = ( shift, shift );
   my $error;
 
 =cut
 
 sub replace {
   my ( $new, $old ) = ( shift, shift );
   my $error;
+  warn "$me replacing $old with $new\n" if $DEBUG;
+
+  return "can't modify system account" if $old->_check_system;
 
   return "Username in use"
     if $old->username ne $new->username &&
 
   return "Username in use"
     if $old->username ne $new->username &&
@@ -699,9 +469,6 @@ sub replace {
     return "Can't change uid!" if $old->uid != $new->uid;
   }
 
     return "Can't change uid!" if $old->uid != $new->uid;
   }
 
-  return "can't change username using Cyrus"
-    if $cyrus_server && $old->username ne $new->username;
-
   #change homdir when we change username
   $new->setfield('dir', '') if $old->username ne $new->username;
 
   #change homdir when we change username
   $new->setfield('dir', '') if $old->username ne $new->username;
 
@@ -716,13 +483,13 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  $error = $new->SUPER::replace($old);
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error if $error;
-  }
+  # redundant, but so $new->usergroup gets set
+  $error = $new->check;
+  return $error if $error;
 
   $old->usergroup( [ $old->radius_groups ] );
 
   $old->usergroup( [ $old->radius_groups ] );
+  warn "old groups: ". join(' ',@{$old->usergroup}). "\n" if $DEBUG;
+  warn "new groups: ". join(' ',@{$new->usergroup}). "\n" if $DEBUG;
   if ( $new->usergroup ) {
     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
     my @newgroups = @{$new->usergroup};
   if ( $new->usergroup ) {
     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
     my @newgroups = @{$new->usergroup};
@@ -756,118 +523,42 @@ sub replace {
 
   }
 
 
   }
 
-  #new-style exports!
-  unless ( $noexport_hack ) {
-    foreach my $part_export ( $new->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_replace($new,$old);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "exporting to ". $part_export->exporttype.
-               " (transaction rolled back): $error";
-      }
-    }
-  }
-
-  #old-style exports
-
-  my ( $old_dir, $new_dir, $uid, $gid ) = (
-    $old->getfield('dir'),
-    $new->getfield('dir'),
-    $new->getfield('uid'),
-    $new->getfield('gid'),
-  );
-  if ( $old_dir && $new_dir && $old_dir ne $new_dir && ! $nossh_hack ) {
-    my $queue = new FS::queue { 
-      'svcnum' => $new->svcnum,
-      'job' => 'Net::SSH::ssh_cmd'
-    };
-    $error = $queue->insert("root\@$shellmachine", eval qq("$usermod") );
+  if ( $old->username ne $new->username || $old->domsvc != $new->domsvc ) {
+    $new->svcpart( $new->cust_svc->svcpart ) unless $new->svcpart;
+    $error = $new->_check_duplicate;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
+      return $error;
     }
   }
 
     }
   }
 
-  if ( $cp_server && $old->username ne $new->username ) {
-    my $queue = new FS::queue { 
-      'svcnum' => $new->svcnum,
-      'job' => 'FS::svc_acct::cp_rename'
-    };
-    $error = $queue->insert( $old->username, $new->username );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing job (transaction rolled back): $error";
-    }
+  $error = $new->SUPER::replace($old);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error if $error;
   }
 
   }
 
-  if ( $vpopdir ) {
-    my $cpassword = crypt(
-      $new->_password,$saltset[int(rand(64))].$saltset[int(rand(64))]
-    );
-
-    if ($old->username ne $new->username || $old->domain ne $new->domain ) {
-      my $queue  = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_delete' };
-        $error = $queue->insert( $old->username, $old->domain );
-      my $queue2 = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_insert' };
-        $error = $queue2->insert( $new->username,
-                                  $cpassword,
-                                  $new->domain,
-                                  $vpopdir,
-                                )
-        unless $error;
-    } elsif ($old->_password ne $new->_password) {
-      my $queue = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_replace_password' };
-      $error = $queue->insert( $new->username, $cpassword, $new->domain );
-    }
+  if ( $new->username ne $old->username ) {
+    #false laziness with sub insert (and cust_main)
+    my $queue = new FS::queue {
+      'svcnum' => $new->svcnum,
+      'job'    => 'FS::svc_acct::append_fuzzyfiles'
+    };
+    $error = $queue->insert($new->username);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
     }
   }
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
     }
   }
 
-  #end of old-style exports
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 }
 
-sub vpopmail_replace_password {
-  my( $username, $password, $domain ) = @_;
-  
-  (open(VPASSWD, "$exportdir/domains/$domain/vpasswd")
-    and flock(VPASSWD,LOCK_EX)
-  ) or die "can't open $exportdir/domains/$domain/vpasswd: $!";
-
-  open(VPASSWDTMP, ">$exportdir/domains/$domain/vpasswd.tmp")
-    or die "Can't open $exportdir/domains/$domain/vpasswd.tmp: $!";
-
-  while (<VPASSWD>) {
-    my ($mailbox, $pw, @rest) = split(':', $_);
-    print VPASSWDTMP $_ unless $username eq $mailbox;
-    print VPASSWDTMP join (':', ($mailbox, $password, @rest))
-      if $username eq $mailbox;
-  }
-
-  close(VPASSWDTMP);
-
-  rename "$exportdir/domains/$domain/vpasswd.tmp", "$exportdir/domains/$domain/vpasswd"
-    or die "Can't rename $exportdir/domains/$domain/vpasswd.tmp: $!";
-
-  flock(VPASSWD,LOCK_UN);
-  close(VPASSWD);
-
-  my $queue = new FS::queue { 'job' => 'FS::svc_acct::vpopmail_sync' };
-  my $error = $queue->insert;
-  die $error if $error;
-
-  1;
-}
-
-
 =item suspend
 
 =item suspend
 
-Suspends this account by prefixing *SUSPENDED* to the password.  If there is an
-error, returns the error, otherwise returns false.
+Suspends this account by calling export-specific suspend hooks.  If there is
+an error, returns the error, otherwise returns false.
 
 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 
 
 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 
@@ -875,22 +566,14 @@ Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 
 sub suspend {
   my $self = shift;
 
 sub suspend {
   my $self = shift;
-  my %hash = $self->hash;
-  unless ( $hash{_password} =~ /^\*SUSPENDED\* /
-           || $hash{_password} eq '*'
-         ) {
-    $hash{_password} = '*SUSPENDED* '.$hash{_password};
-    my $new = new FS::svc_acct ( \%hash );
-    $new->replace($self);
-  } else {
-    ''; #no error (already suspended)
-  }
+  return "can't suspend system account" if $self->_check_system;
+  $self->SUPER::suspend;
 }
 
 =item unsuspend
 
 }
 
 =item unsuspend
 
-Unsuspends this account by removing *SUSPENDED* from the password.  If there is
-an error, returns the error, otherwise returns false.
+Unsuspends this account by by calling export-specific suspend hooks.  If there
+is an error, returns the error, otherwise returns false.
 
 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 
 
 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 
@@ -902,18 +585,45 @@ sub unsuspend {
   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
     $hash{_password} = $1;
     my $new = new FS::svc_acct ( \%hash );
   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
     $hash{_password} = $1;
     my $new = new FS::svc_acct ( \%hash );
-    $new->replace($self);
-  } else {
-    ''; #no error (already unsuspended)
+    my $error = $new->replace($self);
+    return $error if $error;
   }
   }
+
+  $self->SUPER::unsuspend;
 }
 
 =item cancel
 
 }
 
 =item cancel
 
-Just returns false (no error) for now.
-
 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
 
 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
 
+If the B<auto_unset_catchall> configuration option is set, this method will
+automatically remove any references to the canceled service in the catchall
+field of svc_domain.  This allows packages that contain both a svc_domain and
+its catchall svc_acct to be canceled in one step.
+
+=cut
+
+sub cancel {
+  # Only one thing to do at this level
+  my $self = shift;
+  foreach my $svc_domain (
+      qsearch( 'svc_domain', { catchall => $self->svcnum } ) ) {
+    if($conf->exists('auto_unset_catchall')) {
+      my %hash = $svc_domain->hash;
+      $hash{catchall} = '';
+      my $new = new FS::svc_domain ( \%hash );
+      my $error = $new->replace($svc_domain);
+      return $error if $error;
+    } else {
+      return "cannot unprovision svc_acct #".$self->svcnum.
+         " while assigned as catchall for svc_domain #".$svc_domain->svcnum;
+    }
+  }
+
+  $self->SUPER::cancel;
+}
+
+
 =item check
 
 Checks all fields to make sure this is a valid service.  If there is an error,
 =item check
 
 Checks all fields to make sure this is a valid service.  If there is an error,
@@ -939,7 +649,8 @@ sub check {
   }
 
   my $error = $self->ut_numbern('svcnum')
   }
 
   my $error = $self->ut_numbern('svcnum')
-              || $self->ut_number('domsvc')
+              #|| $self->ut_number('domsvc')
+              || $self->ut_foreign_key('domsvc', 'svc_domain', 'svcnum' )
               || $self->ut_textn('sec_phrase')
   ;
   return $error if $error;
               || $self->ut_textn('sec_phrase')
   ;
   return $error if $error;
@@ -947,11 +658,11 @@ sub check {
   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
   if ( $username_uppercase ) {
     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
   if ( $username_uppercase ) {
     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
-      or return gettext('illegal_username'). ": ". $recref->{username};
+      or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
     $recref->{username} = $1;
   } else {
     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
     $recref->{username} = $1;
   } else {
     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
-      or return gettext('illegal_username'). ": ". $recref->{username};
+      or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
     $recref->{username} = $1;
   }
 
     $recref->{username} = $1;
   }
 
@@ -963,6 +674,12 @@ sub check {
   if ( $username_noperiod ) {
     $recref->{username} =~ /\./ and return gettext('illegal_username');
   }
   if ( $username_noperiod ) {
     $recref->{username} =~ /\./ and return gettext('illegal_username');
   }
+  if ( $username_nounderscore ) {
+    $recref->{username} =~ /_/ and return gettext('illegal_username');
+  }
+  if ( $username_nodash ) {
+    $recref->{username} =~ /\-/ and return gettext('illegal_username');
+  }
   unless ( $username_ampersand ) {
     $recref->{username} =~ /\&/ and return gettext('illegal_username');
   }
   unless ( $username_ampersand ) {
     $recref->{username} =~ /\&/ and return gettext('illegal_username');
   }
@@ -984,17 +701,13 @@ sub check {
     #you can set a fixed gid in part_svc
 
     return "Only root can have uid 0"
     #you can set a fixed gid in part_svc
 
     return "Only root can have uid 0"
-      if $recref->{uid} == 0 && $recref->{username} ne 'root';
+      if $recref->{uid} == 0
+         && $recref->{username} ne 'root'
+         && $recref->{username} ne 'toor';
 
 
-#    $error = $self->ut_textn('finger');
-#    return $error if $error;
-    $self->getfield('finger') =~
-      /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\*\<\>]*)$/
-        or return "Illegal finger: ". $self->getfield('finger');
-    $self->setfield('finger', $1);
 
     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
 
     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
-      or return "Illegal directory";
+      or return "Illegal directory: ". $recref->{dir};
     $recref->{dir} = $1;
     return "Illegal directory"
       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
     $recref->{dir} = $1;
     return "Illegal directory"
       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
@@ -1026,29 +739,43 @@ sub check {
       $recref->{shell} = '/bin/sync';
     }
 
       $recref->{shell} = '/bin/sync';
     }
 
-    $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota (unimplemented)";
-    $recref->{quota} = $1;
-
   } else {
     $recref->{gid} ne '' ? 
       return "Can't have gid without uid" : ( $recref->{gid}='' );
   } else {
     $recref->{gid} ne '' ? 
       return "Can't have gid without uid" : ( $recref->{gid}='' );
-    $recref->{finger} ne '' ? 
-      return "Can't have finger-name without uid" : ( $recref->{finger}='' );
     $recref->{dir} ne '' ? 
       return "Can't have directory without uid" : ( $recref->{dir}='' );
     $recref->{shell} ne '' ? 
       return "Can't have shell without uid" : ( $recref->{shell}='' );
     $recref->{dir} ne '' ? 
       return "Can't have directory without uid" : ( $recref->{dir}='' );
     $recref->{shell} ne '' ? 
       return "Can't have shell without uid" : ( $recref->{shell}='' );
-    $recref->{quota} ne '' ? 
-      return "Can't have quota without uid" : ( $recref->{quota}='' );
   }
 
   }
 
+  #  $error = $self->ut_textn('finger');
+  #  return $error if $error;
+  if ( $self->getfield('finger') eq '' ) {
+    my $cust_pkg = $self->svcnum
+      ? $self->cust_svc->cust_pkg
+      : qsearchs('cust_pkg', { 'pkgnum' => $self->getfield('pkgnum') } );
+    if ( $cust_pkg ) {
+      my $cust_main = $cust_pkg->cust_main;
+      $self->setfield('finger', $cust_main->first.' '.$cust_main->get('last') );
+    }
+  }
+  $self->getfield('finger') =~
+    /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
+      or return "Illegal finger: ". $self->getfield('finger');
+  $self->setfield('finger', $1);
+
+  $recref->{quota} =~ /^(\w*)$/ or return "Illegal quota";
+  $recref->{quota} = $1;
+
   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
-    unless ( $recref->{slipip} eq '0e0' ) {
+    if ( $recref->{slipip} eq '' ) {
+      $recref->{slipip} = '';
+    } elsif ( $recref->{slipip} eq '0e0' ) {
+      $recref->{slipip} = '0e0';
+    } else {
       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
-        or return "Illegal slipip". $self->slipip;
+        or return "Illegal slipip". $self->slipip;
       $recref->{slipip} = $1;
       $recref->{slipip} = $1;
-    } else {
-      $recref->{slipip} = '0e0';
     }
 
   }
     }
 
   }
@@ -1072,18 +799,155 @@ sub check {
     #$recref->{password} = $1.
     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
     #;
     #$recref->{password} = $1.
     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
     #;
-  } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$]{13,34})$/ ) {
+  } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;\+]{13,60})$/ ) {
     $recref->{_password} = $1.$3;
   } elsif ( $recref->{_password} eq '*' ) {
     $recref->{_password} = '*';
     $recref->{_password} = $1.$3;
   } elsif ( $recref->{_password} eq '*' ) {
     $recref->{_password} = '*';
+  } elsif ( $recref->{_password} eq '!' ) {
+    $recref->{_password} = '!';
   } elsif ( $recref->{_password} eq '!!' ) {
     $recref->{_password} = '!!';
   } else {
     #return "Illegal password";
   } elsif ( $recref->{_password} eq '!!' ) {
     $recref->{_password} = '!!';
   } else {
     #return "Illegal password";
-    return gettext('illegal_password'). ": ". $recref->{_password};
+    return gettext('illegal_password'). " $passwordmin-$passwordmax ".
+           FS::Msgcat::_gettext('illegal_password_characters').
+           ": ". $recref->{_password};
   }
 
   }
 
-  ''; #no error
+  $self->SUPER::check;
+}
+
+=item _check_system
+
+Internal function to check the username against the list of system usernames
+from the I<system_usernames> configuration value.  Returns true if the username
+is listed on the system username list.
+
+=cut
+
+sub _check_system {
+  my $self = shift;
+  scalar( grep { $self->username eq $_ || $self->email eq $_ }
+               $conf->config('system_usernames')
+        );
+}
+
+=item _check_duplicate
+
+Internal function to check for duplicates usernames, username@domain pairs and
+uids.
+
+If the I<global_unique-username> configuration value is set to B<username> or
+B<username@domain>, enforces global username or username@domain uniqueness.
+
+In all cases, check for duplicate uids and usernames or username@domain pairs
+per export and with identical I<svcpart> values.
+
+=cut
+
+sub _check_duplicate {
+  my $self = shift;
+
+  #this is Pg-specific.  what to do for mysql etc?
+  # ( mysql LOCK TABLES certainly isn't equivalent or useful here :/ )
+  warn "$me locking svc_acct table for duplicate search" if $DEBUG;
+  dbh->do("LOCK TABLE svc_acct IN SHARE ROW EXCLUSIVE MODE")
+    or die dbh->errstr;
+  warn "$me acquired svc_acct table lock for duplicate search" if $DEBUG;
+
+  my $svcpart = $self->svcpart;
+  my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
+  unless ( $part_svc ) {
+    return 'unknown svcpart '. $self->svcpart;
+  }
+
+  my $global_unique = $conf->config('global_unique-username');
+
+  my @dup_user = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
+                 qsearch( 'svc_acct', { 'username' => $self->username } );
+  return gettext('username_in_use')
+    if $global_unique eq 'username' && @dup_user;
+
+  my @dup_userdomain = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
+                       qsearch( 'svc_acct', { 'username' => $self->username,
+                                              'domsvc'   => $self->domsvc } );
+  return gettext('username_in_use')
+    if $global_unique eq 'username@domain' && @dup_userdomain;
+
+  my @dup_uid;
+  if ( $part_svc->part_svc_column('uid')->columnflag ne 'F'
+       && $self->username !~ /^(toor|(hyla)?fax)$/          ) {
+    @dup_uid = grep { $svcpart != $_->svcpart }
+               qsearch( 'svc_acct', { 'uid' => $self->uid } );
+  } else {
+    @dup_uid = ();
+  }
+
+  if ( @dup_user || @dup_userdomain || @dup_uid ) {
+    my $exports = FS::part_export::export_info('svc_acct');
+    my %conflict_user_svcpart;
+    my %conflict_userdomain_svcpart = ( $self->svcpart => 'SELF', );
+
+    foreach my $part_export ( $part_svc->part_export ) {
+
+      #this will catch to the same exact export
+      my @svcparts = map { $_->svcpart } $part_export->export_svc;
+
+      #this will catch to exports w/same exporthost+type ???
+      #my @other_part_export = qsearch('part_export', {
+      #  'machine'    => $part_export->machine,
+      #  'exporttype' => $part_export->exporttype,
+      #} );
+      #foreach my $other_part_export ( @other_part_export ) {
+      #  push @svcparts, map { $_->svcpart }
+      #    qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
+      #}
+
+      #my $nodomain = $exports->{$part_export->exporttype}{'nodomain'};
+      #silly kludge to avoid uninitialized value errors
+      my $nodomain = exists( $exports->{$part_export->exporttype}{'nodomain'} )
+                     ? $exports->{$part_export->exporttype}{'nodomain'}
+                     : '';
+      if ( $nodomain =~ /^Y/i ) {
+        $conflict_user_svcpart{$_} = $part_export->exportnum
+          foreach @svcparts;
+      } else {
+        $conflict_userdomain_svcpart{$_} = $part_export->exportnum
+          foreach @svcparts;
+      }
+    }
+
+    foreach my $dup_user ( @dup_user ) {
+      my $dup_svcpart = $dup_user->cust_svc->svcpart;
+      if ( exists($conflict_user_svcpart{$dup_svcpart}) ) {
+        return "duplicate username: conflicts with svcnum ". $dup_user->svcnum.
+               " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
+      }
+    }
+
+    foreach my $dup_userdomain ( @dup_userdomain ) {
+      my $dup_svcpart = $dup_userdomain->cust_svc->svcpart;
+      if ( exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
+        return "duplicate username\@domain: conflicts with svcnum ".
+               $dup_userdomain->svcnum. " via exportnum ".
+               $conflict_userdomain_svcpart{$dup_svcpart};
+      }
+    }
+
+    foreach my $dup_uid ( @dup_uid ) {
+      my $dup_svcpart = $dup_uid->cust_svc->svcpart;
+      if ( exists($conflict_user_svcpart{$dup_svcpart})
+           || exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
+        return "duplicate uid: conflicts with svcnum". $dup_uid->svcnum.
+               "via exportnum ". $conflict_user_svcpart{$dup_svcpart}
+                                 || $conflict_userdomain_svcpart{$dup_svcpart};
+      }
+    }
+
+  }
+
+  return '';
+
 }
 
 =item radius
 }
 
 =item radius
@@ -1117,8 +981,8 @@ sub radius_reply {
       #$attrib =~ s/_/\-/g;
       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
       #$attrib =~ s/_/\-/g;
       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
-  if ( $self->ip && $self->ip ne '0e0' ) {
-    $reply{'Framed-IP-Address'} = $self->ip;
+  if ( $self->slipip && $self->slipip ne '0e0' ) {
+    $reply{$radius_ip} = $self->slipip;
   }
   %reply;
 }
   }
   %reply;
 }
@@ -1136,7 +1000,9 @@ expected to change in the future.
 
 sub radius_check {
   my $self = shift;
 
 sub radius_check {
   my $self = shift;
-  ( 'Password' => $self->_password,
+  my $password = $self->_password;
+  my $pw_attrib = length($password) <= 12 ? $radius_password : 'Crypt-Password';
+  ( $pw_attrib => $password,
     map {
       /^(rc_(.*))$/;
       my($column, $attrib) = ($1, $2);
     map {
       /^(rc_(.*))$/;
       my($column, $attrib) = ($1, $2);
@@ -1154,20 +1020,16 @@ Returns the domain associated with this account.
 
 sub domain {
   my $self = shift;
 
 sub domain {
   my $self = shift;
-  if ( $self->domsvc ) {
-    #$self->svc_domain->domain;
-    my $svc_domain = $self->svc_domain
-      or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
-    $svc_domain->domain;
-  } else {
-    $mydomain or die "svc_acct.domsvc is null and no legacy domain config file";
-  }
+  die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
+  my $svc_domain = $self->svc_domain
+    or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
+  $svc_domain->domain;
 }
 
 =item svc_domain
 
 Returns the FS::svc_domain record for this account's domain (see
 }
 
 =item svc_domain
 
 Returns the FS::svc_domain record for this account's domain (see
-L<FS::svc_domain>.
+L<FS::svc_domain>).
 
 =cut
 
 
 =cut
 
@@ -1182,6 +1044,8 @@ sub svc_domain {
 
 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
 
 
 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
 
+=cut
+
 sub cust_svc {
   my $self = shift;
   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
 sub cust_svc {
   my $self = shift;
   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
@@ -1198,10 +1062,26 @@ sub email {
   $self->username. '@'. $self->domain;
 }
 
   $self->username. '@'. $self->domain;
 }
 
+=item acct_snarf
+
+Returns an array of FS::acct_snarf records associated with the account.
+If the acct_snarf table does not exist or there are no associated records,
+an empty list is returned
+
+=cut
+
+sub acct_snarf {
+  my $self = shift;
+  return () unless dbdef->table('acct_snarf');
+  eval "use FS::acct_snarf;";
+  die $@ if $@;
+  qsearch('acct_snarf', { 'svcnum' => $self->svcnum } );
+}
+
 =item seconds_since TIMESTAMP
 
 =item seconds_since TIMESTAMP
 
-Returns the number of seconds this account has been online since TIMESTAMP.
-See L<FS::session>
+Returns the number of seconds this account has been online since TIMESTAMP,
+according to the session monitor (see L<FS::Session>).
 
 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
@@ -1214,6 +1094,60 @@ sub seconds_since {
   $self->cust_svc->seconds_since(@_);
 }
 
   $self->cust_svc->seconds_since(@_);
 }
 
+=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
+
+Returns the numbers of seconds this account has been online between
+TIMESTAMP_START (inclusive) and TIMESTAMP_END (exclusive), according to an
+external SQL radacct table, specified via sqlradius export.  Sessions which
+started in the specified range but are still open are counted from session
+start to the end of the range (unless they are over 1 day old, in which case
+they are presumed missing their stop record and not counted).  Also, sessions
+which end in the range but started earlier are counted from the start of the
+range to session end.  Finally, sessions which start before the range but end
+after are counted for the entire range.
+
+TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
+L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
+functions.
+
+=cut
+
+#note: POD here, implementation in FS::cust_svc
+sub seconds_since_sqlradacct {
+  my $self = shift;
+  $self->cust_svc->seconds_since_sqlradacct(@_);
+}
+
+=item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
+
+Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
+in this package for sessions ending between TIMESTAMP_START (inclusive) and
+TIMESTAMP_END (exclusive).
+
+TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
+L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
+functions.
+
+=cut
+
+#note: POD here, implementation in FS::cust_svc
+sub attribute_since_sqlradacct {
+  my $self = shift;
+  $self->cust_svc->attribute_since_sqlradacct(@_);
+}
+
+=item get_session_history_sqlradacct TIMESTAMP_START TIMESTAMP_END
+
+Returns an array of hash references of this customers login history for the
+given time range.  (document this better)
+
+=cut
+
+sub get_session_history_sqlradacct {
+  my $self = shift;
+  $self->cust_svc->get_session_history_sqlradacct(@_);
+}
+
 =item radius_groups
 
 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
 =item radius_groups
 
 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
@@ -1222,14 +1156,227 @@ Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
 
 sub radius_groups {
   my $self = shift;
 
 sub radius_groups {
   my $self = shift;
-  map { $_->groupname }
-    qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
+  if ( $self->usergroup ) {
+    #when provisioning records, export callback runs in svc_Common.pm before
+    #radius_usergroup records can be inserted...
+    @{$self->usergroup};
+  } else {
+    map { $_->groupname }
+      qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
+  }
+}
+
+=item clone_suspended
+
+Constructor used by FS::part_export::_export_suspend fallback.  Document
+better.
+
+=cut
+
+sub clone_suspended {
+  my $self = shift;
+  my %hash = $self->hash;
+  $hash{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
+  new FS::svc_acct \%hash;
+}
+
+=item clone_kludge_unsuspend 
+
+Constructor used by FS::part_export::_export_unsuspend fallback.  Document
+better.
+
+=cut
+
+sub clone_kludge_unsuspend {
+  my $self = shift;
+  my %hash = $self->hash;
+  $hash{_password} = '';
+  new FS::svc_acct \%hash;
+}
+
+=item check_password 
+
+Checks the supplied password against the (possibly encrypted) password in the
+database.  Returns true for a sucessful authentication, false for no match.
+
+Currently supported encryptions are: classic DES crypt() and MD5
+
+=cut
+
+sub check_password {
+  my($self, $check_password) = @_;
+
+  #remove old-style SUSPENDED kludge, they should be allowed to login to
+  #self-service and pay up
+  ( my $password = $self->_password ) =~ s/^\*SUSPENDED\* //;
+
+  #eventually should check a "password-encoding" field
+  if ( $password =~ /^(\*|!!?)$/ ) { #no self-service login
+    return 0;
+  } elsif ( length($password) < 13 ) { #plaintext
+    $check_password eq $password;
+  } elsif ( length($password) == 13 ) { #traditional DES crypt
+    crypt($check_password, $password) eq $password;
+  } elsif ( $password =~ /^\$1\$/ ) { #MD5 crypt
+    unix_md5_crypt($check_password, $password) eq $password;
+  } elsif ( $password =~ /^\$2a?\$/ ) { #Blowfish
+    warn "Can't check password: Blowfish encryption not yet supported, svcnum".
+         $self->svcnum. "\n";
+    0;
+  } else {
+    warn "Can't check password: Unrecognized encryption for svcnum ".
+         $self->svcnum. "\n";
+    0;
+  }
+
+}
+
+=item crypt_password
+
+Returns an encrypted password, either by passing through an encrypted password
+in the database or by encrypting a plaintext password from the database.
+
+=cut
+
+sub crypt_password {
+  my $self = shift;
+  #false laziness w/shellcommands.pm
+  #eventually should check a "password-encoding" field
+  if ( length($self->_password) == 13
+       || $self->_password =~ /^\$(1|2a?)\$/ ) {
+    $self->_password;
+  } else {
+    crypt(
+      $self->_password,
+      $saltset[int(rand(64))].$saltset[int(rand(64))]
+    );
+  }
+}
+
+=item virtual_maildir
+
+Returns $domain/maildirs/$username/
+
+=cut
+
+sub virtual_maildir {
+  my $self = shift;
+  $self->domain. '/maildirs/'. $self->username. '/';
 }
 
 =back
 
 =head1 SUBROUTINES
 
 }
 
 =back
 
 =head1 SUBROUTINES
 
+=over 4
+
+=item send_email
+
+This is the FS::svc_acct job-queue-able version.  It still uses
+FS::Misc::send_email under-the-hood.
+
+=cut
+
+sub send_email {
+  my %opt = @_;
+
+  eval "use FS::Misc qw(send_email)";
+  die $@ if $@;
+
+  $opt{mimetype} ||= 'text/plain';
+  $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
+
+  my $error = send_email(
+    'from'         => $opt{from},
+    'to'           => $opt{to},
+    'subject'      => $opt{subject},
+    'content-type' => $opt{mimetype},
+    'body'         => [ map "$_\n", split("\n", $opt{body}) ],
+  );
+  die $error if $error;
+}
+
+=item check_and_rebuild_fuzzyfiles
+
+=cut
+
+sub check_and_rebuild_fuzzyfiles {
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  -e "$dir/svc_acct.username"
+    or &rebuild_fuzzyfiles;
+}
+
+=item rebuild_fuzzyfiles
+
+=cut
+
+sub rebuild_fuzzyfiles {
+
+  use Fcntl qw(:flock);
+
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+
+  #username
+
+  open(USERNAMELOCK,">>$dir/svc_acct.username")
+    or die "can't open $dir/svc_acct.username: $!";
+  flock(USERNAMELOCK,LOCK_EX)
+    or die "can't lock $dir/svc_acct.username: $!";
+
+  my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
+
+  open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
+    or die "can't open $dir/svc_acct.username.tmp: $!";
+  print USERNAMECACHE join("\n", @all_username), "\n";
+  close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
+
+  rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
+  close USERNAMELOCK;
+
+}
+
+=item all_username
+
+=cut
+
+sub all_username {
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  open(USERNAMECACHE,"<$dir/svc_acct.username")
+    or die "can't open $dir/svc_acct.username: $!";
+  my @array = map { chomp; $_; } <USERNAMECACHE>;
+  close USERNAMECACHE;
+  \@array;
+}
+
+=item append_fuzzyfiles USERNAME
+
+=cut
+
+sub append_fuzzyfiles {
+  my $username = shift;
+
+  &check_and_rebuild_fuzzyfiles;
+
+  use Fcntl qw(:flock);
+
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+
+  open(USERNAME,">>$dir/svc_acct.username")
+    or die "can't open $dir/svc_acct.username: $!";
+  flock(USERNAME,LOCK_EX)
+    or die "can't lock $dir/svc_acct.username: $!";
+
+  print USERNAME "$username\n";
+
+  flock(USERNAME,LOCK_UN)
+    or die "can't unlock $dir/svc_acct.username: $!";
+  close USERNAME;
+
+  1;
+}
+
+
+
 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
 
 =cut
 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
 
 =cut
@@ -1279,6 +1426,8 @@ END
   $html;
 }
 
   $html;
 }
 
+=back
+
 =head1 BUGS
 
 The $recref stuff in sub check should be cleaned up.
 =head1 BUGS
 
 The $recref stuff in sub check should be cleaned up.
@@ -1290,12 +1439,15 @@ counterintuitive.
 radius_usergroup_selector?  putting web ui components in here?  they should
 probably live somewhere else...
 
 radius_usergroup_selector?  putting web ui components in here?  they should
 probably live somewhere else...
 
+insertion of RADIUS group stuff in insert could be done with child_objects now
+(would probably clean up export of them too)
+
 =head1 SEE ALSO
 
 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
 =head1 SEE ALSO
 
 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
-L<freeside-queued>), L<Net::SSH>, L<ssh>, L<FS::svc_acct_pop>,
+L<freeside-queued>), L<FS::svc_acct_pop>,
 schema.html from the base documentation.
 
 =cut
 schema.html from the base documentation.
 
 =cut