communigate (phase 2): Account Preferences (& Domain::Account Defaults:Preferences...
[freeside.git] / FS / FS / svc_acct.pm
index c301b87..5093841 100644 (file)
@@ -1,33 +1,43 @@
 package FS::svc_acct;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf $skip_fuzzyfiles
+use base qw( FS::svc_Domain_Mixin FS::svc_Common );
+use vars qw( $DEBUG $me $conf $skip_fuzzyfiles
              $dir_prefix @shells $usernamemin
              $usernamemax $passwordmin $passwordmax
              $username_ampersand $username_letter $username_letterfirst
              $username_noperiod $username_nounderscore $username_nodash
-             $username_uppercase $username_percent
+             $username_uppercase $username_percent $username_colon
              $password_noampersand $password_noexclamation
-             $welcome_template $welcome_from $welcome_subject $welcome_mimetype
+             $warning_template $warning_from $warning_subject $warning_mimetype
+             $warning_cc
              $smtpmachine
              $radius_password $radius_ip
              $dirhash
              @saltset @pw_set );
+use Scalar::Util qw( blessed );
+use Math::BigInt;
 use Carp;
 use Fcntl qw(:flock);
 use Date::Format;
 use Crypt::PasswdMD5 1.2;
+use Digest::SHA1 'sha1_base64';
+use Digest::MD5 'md5_base64';
 use Data::Dumper;
-use FS::UID qw( datasrc );
+use Text::Template;
+use Authen::Passphrase;
+use FS::UID qw( datasrc driver_name );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs fields dbh dbdef );
 use FS::Msgcat qw(gettext);
-use FS::svc_Common;
-use FS::cust_svc;
+use FS::UI::bytecount;
+use FS::UI::Web;
+use FS::part_pkg;
 use FS::part_svc;
 use FS::svc_acct_pop;
 use FS::cust_main_invoice;
 use FS::svc_domain;
+use FS::svc_pbx;
 use FS::raddb;
 use FS::queue;
 use FS::radius_usergroup;
@@ -37,19 +47,21 @@ use FS::svc_forward;
 use FS::svc_www;
 use FS::cdr;
 
-@ISA = qw( FS::svc_Common );
-
 $DEBUG = 0;
 $me = '[FS::svc_acct]';
 
 #ask FS::UID to run this stuff for us later
-$FS::UID::callback{'FS::svc_acct'} = sub { 
+FS::UID->install_callback( sub { 
   $conf = new FS::Conf;
   $dir_prefix = $conf->config('home');
   @shells = $conf->config('shells');
   $usernamemin = $conf->config('usernamemin') || 2;
   $usernamemax = $conf->config('usernamemax');
-  $passwordmin = $conf->config('passwordmin') || 6;
+  $passwordmin = $conf->config('passwordmin'); # || 6;
+  #blank->6, keep 0
+  $passwordmin = ( defined($passwordmin) && $passwordmin =~ /\d+/ )
+                   ? $passwordmin
+                   : 6;
   $passwordmax = $conf->config('passwordmax') || 8;
   $username_letter = $conf->exists('username-letter');
   $username_letterfirst = $conf->exists('username-letterfirst');
@@ -59,27 +71,32 @@ $FS::UID::callback{'FS::svc_acct'} = sub {
   $username_uppercase = $conf->exists('username-uppercase');
   $username_ampersand = $conf->exists('username-ampersand');
   $username_percent = $conf->exists('username-percent');
+  $username_colon = $conf->exists('username-colon');
   $password_noampersand = $conf->exists('password-noexclamation');
   $password_noexclamation = $conf->exists('password-noexclamation');
   $dirhash = $conf->config('dirhash') || 0;
-  if ( $conf->exists('welcome_email') ) {
-    $welcome_template = new Text::Template (
+  if ( $conf->exists('warning_email') ) {
+    $warning_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';
+      SOURCE => [ map "$_\n", $conf->config('warning_email') ]
+    ) or warn "can't create warning email template: $Text::Template::ERROR";
+    $warning_from = $conf->config('warning_email-from'); # || 'your-isp-is-dum'
+    $warning_subject = $conf->config('warning_email-subject') || 'Warning';
+    $warning_mimetype = $conf->config('warning_email-mimetype') || 'text/plain';
+    $warning_cc = $conf->config('warning_email-cc');
   } else {
-    $welcome_template = '';
-    $welcome_from = '';
-    $welcome_subject = '';
-    $welcome_mimetype = '';
+    $warning_template = '';
+    $warning_from = '';
+    $warning_subject = '';
+    $warning_mimetype = '';
+    $warning_cc = '';
   }
   $smtpmachine = $conf->config('smtpmachine');
   $radius_password = $conf->config('radius-password') || 'Password';
   $radius_ip = $conf->config('radius-ip') || 'Framed-IP-Address';
-};
+  @pw_set = ( 'A'..'Z' ) if $conf->exists('password-generated-allcaps');
+}
+);
 
 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
@@ -142,37 +159,71 @@ FS::svc_Common.  The following fields are currently supported:
 
 =over 4
 
-=item svcnum - primary key (assigned automatcially for new accounts)
+=item svcnum
+
+Primary key (assigned automatcially for new accounts)
 
 =item username
 
-=item _password - generated if blank
+=item _password
+
+generated if blank
+
+=item _password_encoding
+
+plain, crypt, ldap (or empty for autodetection)
+
+=item sec_phrase
 
-=item sec_phrase - security phrase
+security phrase
 
-=item popnum - Point of presence (see L<FS::svc_acct_pop>)
+=item popnum
+
+Point of presence (see L<FS::svc_acct_pop>)
 
 =item uid
 
 =item gid
 
-=item finger - GECOS
+=item finger
+
+GECOS
 
-=item dir - set automatically if blank (and uid is not)
+=item dir
+
+set automatically if blank (and uid is not)
 
 =item shell
 
-=item quota - (unimplementd)
+=item quota
+
+=item slipip
+
+IP address
+
+=item seconds
+
+=item upbytes
+
+=item downbyte
 
-=item slipip - IP address
+=item totalbytes
 
-=item seconds - 
+=item domsvc
 
-=item domsvc - svcnum from svc_domain
+svcnum from svc_domain
 
-=item radius_I<Radius_Attribute> - I<Radius-Attribute> (reply)
+=item pbxsvc
 
-=item rc_I<Radius_Attribute> - I<Radius-Attribute> (check)
+Optional svcnum from svc_pbx
+
+=item radius_I<Radius_Attribute>
+
+I<Radius-Attribute> (reply)
+
+=item rc_I<Radius_Attribute>
+
+I<Radius-Attribute> (check)
 
 =back
 
@@ -186,8 +237,334 @@ Creates a new account.  To add the account to the database, see L<"insert">.
 
 =cut
 
+sub table_info {
+  {
+    'name'   => 'Account',
+    'longname_plural' => 'Access accounts and mailboxes',
+    'sorts' => [ 'username', 'uid', 'seconds', 'last_login' ],
+    'display_weight' => 10,
+    'cancel_weight'  => 50, 
+    'fields' => {
+        'dir'       => 'Home directory',
+        'uid'       => {
+                         label    => 'UID',
+                        def_info => 'set to fixed and blank for no UIDs',
+                        type     => 'text',
+                      },
+        'slipip'    => 'IP address',
+    #    'popnum'    => qq!<A HREF="$p/browse/svc_acct_pop.cgi/">POP number</A>!,
+        'popnum'    => {
+                         label => 'Access number',
+                         type => 'select',
+                         select_table => 'svc_acct_pop',
+                         select_key   => 'popnum',
+                         select_label => 'city',
+                         disable_select => 1,
+                       },
+        'username'  => {
+                         label => 'Username',
+                         type => 'text',
+                         disable_default => 1,
+                         disable_fixed => 1,
+                         disable_select => 1,
+                       },
+        'password_selfchange' => { label => 'Password modification',
+                                   type  => 'checkbox',
+                                 },
+        'password_recover'    => { label => 'Password recovery',
+                                   type  => 'checkbox',
+                                 },
+        'quota'     => { 
+                         label => 'Quota', #Mail storage limit
+                         type => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        'file_quota'=> { 
+                         label => 'File storage limit',
+                         type => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        'file_maxnum'=> { 
+                         label => 'Number of files limit',
+                         type => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        'file_maxsize'=> { 
+                         label => 'File size limit',
+                         type => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        '_password' => 'Password',
+        'gid'       => {
+                         label    => 'GID',
+                        def_info => 'when blank, defaults to UID',
+                        type     => 'text',
+                      },
+        'shell'     => {
+                        label    => 'Shell',
+                         def_info => 'set to blank for no shell tracking',
+                         type     => 'select',
+                         #select_list => [ $conf->config('shells') ],
+                         select_list => [ $conf ? $conf->config('shells') : () ],
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        'finger'    => 'Real name', # (GECOS)',
+        'domsvc'    => {
+                         label     => 'Domain',
+                         type      => 'select',
+                         select_table => 'svc_domain',
+                         select_key   => 'svcnum',
+                         select_label => 'domain',
+                         disable_inventory => 1,
+                       },
+        'pbxsvc'    => { label => 'PBX',
+                         type  => 'select-svc_pbx.html',
+                         disable_inventory => 1,
+                         disable_select => 1, #UI wonky, pry works otherwise
+                       },
+        'usergroup' => {
+                         label => 'RADIUS groups',
+                         type  => 'radius_usergroup_selector',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                       },
+        'seconds'   => { label => 'Seconds',
+                         label_sort => 'with Time Remaining',
+                         type  => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                         disable_part_svc_column => 1,
+                       },
+        'upbytes'   => { label => 'Upload',
+                         type  => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                         'format' => \&FS::UI::bytecount::display_bytecount,
+                         'parse' => \&FS::UI::bytecount::parse_bytecount,
+                         disable_part_svc_column => 1,
+                       },
+        'downbytes' => { label => 'Download',
+                         type  => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                         'format' => \&FS::UI::bytecount::display_bytecount,
+                         'parse' => \&FS::UI::bytecount::parse_bytecount,
+                         disable_part_svc_column => 1,
+                       },
+        'totalbytes'=> { label => 'Total up and download',
+                         type  => 'text',
+                         disable_inventory => 1,
+                         disable_select => 1,
+                         'format' => \&FS::UI::bytecount::display_bytecount,
+                         'parse' => \&FS::UI::bytecount::parse_bytecount,
+                         disable_part_svc_column => 1,
+                       },
+        'seconds_threshold'   => { label => 'Seconds threshold',
+                                   type  => 'text',
+                                   disable_inventory => 1,
+                                   disable_select => 1,
+                                   disable_part_svc_column => 1,
+                                 },
+        'upbytes_threshold'   => { label => 'Upload threshold',
+                                   type  => 'text',
+                                   disable_inventory => 1,
+                                   disable_select => 1,
+                                   'format' => \&FS::UI::bytecount::display_bytecount,
+                                   'parse' => \&FS::UI::bytecount::parse_bytecount,
+                                   disable_part_svc_column => 1,
+                                 },
+        'downbytes_threshold' => { label => 'Download threshold',
+                                   type  => 'text',
+                                   disable_inventory => 1,
+                                   disable_select => 1,
+                                   'format' => \&FS::UI::bytecount::display_bytecount,
+                                   'parse' => \&FS::UI::bytecount::parse_bytecount,
+                                   disable_part_svc_column => 1,
+                                 },
+        'totalbytes_threshold'=> { label => 'Total up and download threshold',
+                                   type  => 'text',
+                                   disable_inventory => 1,
+                                   disable_select => 1,
+                                   'format' => \&FS::UI::bytecount::display_bytecount,
+                                   'parse' => \&FS::UI::bytecount::parse_bytecount,
+                                   disable_part_svc_column => 1,
+                                 },
+        'last_login'=>           {
+                                   label     => 'Last login',
+                                   type      => 'disabled',
+                                 },
+        'last_logout'=>          {
+                                   label     => 'Last logout',
+                                   type      => 'disabled',
+                                 },
+
+        'cgp_aliases' => { 
+                           label => 'Communigate aliases',
+                           type  => 'text',
+                           disable_inventory => 1,
+                           disable_select    => 1,
+                         },
+        #settings
+        'cgp_type'=> { 
+                       label => 'Communigate account type',
+                       type => 'select',
+                       select_list => [qw( MultiMailbox TextMailbox MailDirMailbox AGrade BGrade CGrade )],
+                       disable_inventory => 1,
+                       disable_select    => 1,
+                     },
+        'cgp_accessmodes' => { 
+                               label => 'Communigate enabled services',
+                               type  => 'communigate_pro-accessmodes',
+                               disable_inventory => 1,
+                               disable_select    => 1,
+                             },
+        'cgp_rulesallowed'   => {
+          label       => 'Allowed mail rules',
+          type        => 'select',
+          select_list => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
+          disable_inventory => 1,
+          disable_select    => 1,
+        },
+        'cgp_rpopallowed'    => { label => 'RPOP modifications',
+                                  type  => 'checkbox',
+                                },
+        'cgp_mailtoall'      => { label => 'Accepts mail to "all"',
+                                  type  => 'checkbox',
+                                },
+        'cgp_addmailtrailer' => { label => 'Add trailer to sent mail',
+                                  type  => 'checkbox',
+                                },
+        #XXX archive messages, mailing lists
+
+        #preferences
+        'cgp_deletemode' => { 
+                              label => 'Communigate message delete method',
+                              type  => 'select',
+                              select_list => [ 'Move To Trash', 'Immediately', 'Mark' ],
+                              disable_inventory => 1,
+                              disable_select    => 1,
+                            },
+        'cgp_emptytrash' => { 
+                              label => 'Communigate on logout remove trash',
+                              type  => 'text',
+                              disable_inventory => 1,
+                              disable_select    => 1,
+                            },
+        'cgp_language' => {
+                            label => 'Communigate language',
+                            type  => 'select',
+                            select_list => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
+                            disable_inventory => 1,
+                            disable_select    => 1,
+                          },
+        'cgp_timezone' => {
+                            label => 'Communigate time zone',
+                            type  => 'select',
+                            select_list => [ '',
+                                             'HostOS',
+                                             '(+0100) Algeria/Congo',
+                                             '(+0200) Egypt/South Africa',
+                                             '(+0300) Saudi Arabia',
+                                             '(+0400) Oman',
+                                             '(+0500) Pakistan',
+                                             '(+0600) Bangladesh',
+                                             '(+0700) Thailand/Vietnam',
+                                             '(+0800) China/Malaysia',
+                                             '(+0900) Japan/Korea',
+                                             '(+1000) Queensland',
+                                             '(+1100) Micronesia',
+                                             '(+1200) Fiji',
+                                             '(+1300) Tonga/Kiribati',
+                                             '(+1400) Christmas Islands',
+                                             '(-0100) Azores/Cape Verde',
+                                             '(-0200) Fernando de Noronha',
+                                             '(-0300) Argentina/Uruguay',
+                                             '(-0400) Venezuela/Guyana',
+                                             '(-0500) Haiti/Peru',
+                                             '(-0600) Central America',
+                                             '(-0700) Arisona',
+                                             '(-0800) Adamstown',
+                                             '(-0900) Marquesas Islands',
+                                             '(-1000) Hawaii/Tahiti',
+                                             '(-1100) Samoa',
+                                             'Asia/Afghanistan',
+                                             'Asia/India',
+                                             'Asia/Iran',
+                                             'Asia/Iraq',
+                                             'Asia/Israel',
+                                             'Asia/Jordan',
+                                             'Asia/Lebanon',
+                                             'Asia/Syria',
+                                             'Australia/Adelaide',
+                                             'Australia/East',
+                                             'Australia/NorthernTerritory',
+                                             'Europe/Central',
+                                             'Europe/Eastern',
+                                             'Europe/Moscow',
+                                             'Europe/Western',
+                                             'GMT (+0000)',
+                                             'Newfoundland',
+                                             'NewZealand/Auckland',
+                                             'NorthAmerica/Alaska',
+                                             'NorthAmerica/Atlantic',
+                                             'NorthAmerica/Central',
+                                             'NorthAmerica/Eastern',
+                                             'NorthAmerica/Mountain',
+                                             'NorthAmerica/Pacific',
+                                             'Russia/Ekaterinburg',
+                                             'Russia/Irkutsk',
+                                             'Russia/Kamchatka',
+                                             'Russia/Krasnoyarsk',
+                                             'Russia/Magadan',
+                                             'Russia/Novosibirsk',
+                                             'Russia/Vladivostok',
+                                             'Russia/Yakutsk',
+                                             'SouthAmerica/Brasil',
+                                             'SouthAmerica/Chile',
+                                             'SouthAmerica/Paraguay',
+                                           ],
+                            disable_inventory => 1,
+                            disable_select    => 1,
+                          },
+        'cgp_skinname' => {
+                            label => 'Communigate layout',
+                            type  => 'select',
+                            select_list => [ '', '***', 'GoldFleece', 'Skin2' ],
+                            disable_inventory => 1,
+                            disable_select    => 1,
+                          },
+        'cgp_prontoskinname' => {
+                            label => 'Communigate Pronto style',
+                            type  => 'select',
+                            select_list => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
+                            disable_inventory => 1,
+                            disable_select    => 1,
+                          },
+        'cgp_sendmdnmode' => {
+          label => 'Communigate send read receipts',
+          type  => 'select',
+          select_list => [ '', 'Never', 'Manually', 'Automatically' ],
+          disable_inventory => 1,
+          disable_select    => 1,
+        },
+
+        #mail
+        #XXX vacation message, redirect all mail, mail rules
+        #XXX RPOP settings
+
+    },
+  };
+}
+
 sub table { 'svc_acct'; }
 
+sub table_dupcheck_fields { ( 'username', 'domsvc' ); }
+
 sub _fieldhandlers {
   {
     #false laziness with edit/svc_acct.cgi
@@ -204,6 +581,106 @@ sub _fieldhandlers {
   };
 }
 
+sub last_login {
+  shift->_lastlog('in', @_);
+}
+
+sub last_logout {
+  shift->_lastlog('out', @_);
+}
+
+sub _lastlog {
+  my( $self, $op, $time ) = @_;
+
+  if ( defined($time) ) {
+    warn "$me last_log$op called on svcnum ". $self->svcnum.
+         ' ('. $self->email. "): $time\n"
+      if $DEBUG;
+
+    my $dbh = dbh;
+
+    my $sql = "UPDATE svc_acct SET last_log$op = ? WHERE svcnum = ?";
+    warn "$me $sql\n"
+      if $DEBUG;
+
+    my $sth = $dbh->prepare( $sql )
+      or die "Error preparing $sql: ". $dbh->errstr;
+    my $rv = $sth->execute($time, $self->svcnum);
+    die "Error executing $sql: ". $sth->errstr
+      unless defined($rv);
+    die "Can't update last_log$op for svcnum". $self->svcnum
+      if $rv == 0;
+
+    $self->{'Hash'}->{"last_log$op"} = $time;
+  }else{
+    $self->getfield("last_log$op");
+  }
+}
+
+=item search_sql STRING
+
+Class method which returns an SQL fragment to search for the given string.
+
+=cut
+
+sub search_sql {
+  my( $class, $string ) = @_;
+  if ( $string =~ /^([^@]+)@([^@]+)$/ ) {
+    my( $username, $domain ) = ( $1, $2 );
+    my $q_username = dbh->quote($username);
+    my @svc_domain = qsearch('svc_domain', { 'domain' => $domain } );
+    if ( @svc_domain ) {
+      "svc_acct.username = $q_username AND ( ".
+        join( ' OR ', map { "svc_acct.domsvc = ". $_->svcnum; } @svc_domain ).
+      " )";
+    } else {
+      '1 = 0'; #false
+    }
+  } elsif ( $string =~ /^(\d{1,3}\.){3}\d{1,3}$/ ) {
+    ' ( '.
+      $class->search_sql_field('slipip',   $string ).
+    ' OR '.
+      $class->search_sql_field('username', $string ).
+    ' ) ';
+  } else {
+    $class->search_sql_field('username', $string);
+  }
+}
+
+=item label [ END_TIMESTAMP [ START_TIMESTAMP ] ]
+
+Returns the "username@domain" string for this account.
+
+END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
+history records.
+
+=cut
+
+sub label {
+  my $self = shift;
+  $self->email(@_);
+}
+
+=item label_long [ END_TIMESTAMP [ START_TIMESTAMP ] ]
+
+Returns a longer string label for this acccount ("Real Name <username@domain>"
+if available, or "username@domain").
+
+END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
+history records.
+
+=cut
+
+sub label_long {
+  my $self = shift;
+  my $label = $self->label(@_);
+  my $finger = $self->finger;
+  return $label unless $finger =~ /\S/;
+  my $maxlen = 40 - length($label) - length($self->cust_svc->part_svc->svc);
+  $finger = substr($finger, 0, $maxlen-3).'...' if length($finger) > $maxlen;
+  "$finger <$label>";
+}
+
 =item insert [ , OPTION => VALUE ... ]
 
 Adds this account to the database.  If there is an error, returns the error,
@@ -255,27 +732,8 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $self->check;
-  return $error if $error;
-
-  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;
-      return "no cust_svc record found for svcnum ". $self->svcnum;
-    }
-    $self->pkgnum($cust_svc->pkgnum);
-    $self->svcpart($cust_svc->svcpart);
-  }
-
-  $error = $self->_check_duplicate;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
   my @jobnums;
-  $error = $self->SUPER::insert(
+  my $error = $self->SUPER::insert(
     'jobnums'       => \@jobnums,
     'child_objects' => $self->child_objects,
     %options,
@@ -311,18 +769,49 @@ sub insert {
 
   if ( $cust_pkg ) {
     my $cust_main = $cust_pkg->cust_main;
+    my $agentnum = $cust_main->agentnum;
 
-    if ( $conf->exists('emailinvoiceauto') ) {
+    if (   $conf->exists('emailinvoiceautoalways')
+        || $conf->exists('emailinvoiceauto')
+        && ! $cust_main->invoicing_list_emailonly
+       ) {
       my @invoicing_list = $cust_main->invoicing_list;
       push @invoicing_list, $self->email;
       $cust_main->invoicing_list(\@invoicing_list);
     }
 
     #welcome email
-    my $to = '';
+    my ($to,$welcome_template,$welcome_from,$welcome_subject,$welcome_subject_template,$welcome_mimetype)
+      = ('','','','','','');
+
+    if ( $conf->exists('welcome_email', $agentnum) ) {
+      $welcome_template = new Text::Template (
+        TYPE   => 'ARRAY',
+        SOURCE => [ map "$_\n", $conf->config('welcome_email', $agentnum) ]
+      ) or warn "can't create welcome email template: $Text::Template::ERROR";
+      $welcome_from = $conf->config('welcome_email-from', $agentnum);
+        # || 'your-isp-is-dum'
+      $welcome_subject = $conf->config('welcome_email-subject', $agentnum)
+        || 'Welcome';
+      $welcome_subject_template = new Text::Template (
+        TYPE   => 'STRING',
+        SOURCE => $welcome_subject,
+      ) or warn "can't create welcome email subject template: $Text::Template::ERROR";
+      $welcome_mimetype = $conf->config('welcome_email-mimetype', $agentnum)
+        || 'text/plain';
+    }
     if ( $welcome_template && $cust_pkg ) {
       my $to = join(', ', grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list );
       if ( $to ) {
+
+        my %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,
+                   );
         my $wqueue = new FS::queue {
           'svcnum' => $self->svcnum,
           'job'    => 'FS::svc_acct::send_email'
@@ -330,16 +819,9 @@ sub insert {
         my $error = $wqueue->insert(
           'to'       => $to,
           'from'     => $welcome_from,
-          'subject'  => $welcome_subject,
+          'subject'  => $welcome_subject_template->fill_in( HASH => \%hash, ),
           '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,
-                        } ),
+          'body'     => $welcome_template->fill_in( HASH => \%hash, ),
         );
         if ( $error ) {
           $dbh->rollback if $oldAutoCommit;
@@ -380,6 +862,34 @@ sub insert {
   ''; #no error
 }
 
+# set usage fields and thresholds if unset but set in a package def
+# AND the package already has a last bill date (otherwise they get double added)
+sub preinsert_hook_first {
+  my $self = shift;
+
+  return '' unless $self->pkgnum;
+
+  my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
+  return '' unless $cust_pkg && $cust_pkg->last_bill;
+
+  my $part_pkg = $cust_pkg->part_pkg;
+  return '' unless $part_pkg && $part_pkg->can('usage_valuehash');
+
+  my %values = $part_pkg->usage_valuehash;
+  my $multiplier = $conf->exists('svc_acct-usage_threshold') 
+                     ? 1 - $conf->config('svc_acct-usage_threshold')/100
+                     : 0.20; #doesn't matter
+
+  foreach ( keys %values ) {
+    next if $self->getfield($_);
+    $self->setfield( $_, $values{$_} );
+    $self->setfield( $_. '_threshold', int( $values{$_} * $multiplier ) )
+      if $conf->exists('svc_acct-usage_threshold');
+  }
+
+  ''; #no error
+}
+
 =item delete
 
 Deletes this account from the database.  If there is an error, returns the
@@ -448,6 +958,12 @@ sub delete {
     }
   }
 
+  my $error = $self->SUPER::delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
   foreach my $radius_usergroup (
     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
   ) {
@@ -458,12 +974,6 @@ sub delete {
     }
   }
 
-  my $error = $self->SUPER::delete;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
@@ -480,14 +990,15 @@ contain an arrayref of group names.  See L<FS::radius_usergroup>.
 =cut
 
 sub replace {
-  my ( $new, $old ) = ( shift, shift );
-  my $error;
+  my $new = shift;
+
+  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
+              ? shift
+              : $new->replace_old;
+
   warn "$me replacing $old with $new\n" if $DEBUG;
 
-  # We absolutely have to have an old vs. new record to make this work.
-  if (!defined($old)) {
-    $old = qsearchs( 'svc_acct', { 'svcnum' => $new->svcnum } );
-  }
+  my $error;
 
   return "can't modify system account" if $old->_check_system;
 
@@ -561,16 +1072,7 @@ sub replace {
 
   }
 
-  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;
-      return $error;
-    }
-  }
-
-  $error = $new->SUPER::replace($old);
+  $error = $new->SUPER::replace($old, @_);
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error if $error;
@@ -636,7 +1138,7 @@ Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
 sub suspend {
   my $self = shift;
   return "can't suspend system account" if $self->_check_system;
-  $self->SUPER::suspend;
+  $self->SUPER::suspend(@_);
 }
 
 =item unsuspend
@@ -658,7 +1160,7 @@ sub unsuspend {
     return $error if $error;
   }
 
-  $self->SUPER::unsuspend;
+  $self->SUPER::unsuspend(@_);
 }
 
 =item cancel
@@ -689,7 +1191,7 @@ sub cancel {
     }
   }
 
-  $self->SUPER::cancel;
+  $self->SUPER::cancel(@_);
 }
 
 
@@ -719,18 +1221,59 @@ sub check {
 
   my $error = $self->ut_numbern('svcnum')
               #|| $self->ut_number('domsvc')
-              || $self->ut_foreign_key('domsvc', 'svc_domain', 'svcnum' )
+              || $self->ut_foreign_key( 'domsvc', 'svc_domain', 'svcnum' )
+              || $self->ut_foreign_keyn('pbxsvc', 'svc_pbx',    'svcnum' )
               || $self->ut_textn('sec_phrase')
+              || $self->ut_snumbern('seconds')
+              || $self->ut_snumbern('upbytes')
+              || $self->ut_snumbern('downbytes')
+              || $self->ut_snumbern('totalbytes')
+              || $self->ut_enum('_password_encoding', ['',qw(plain crypt ldap)])
+              || $self->ut_enum('password_selfchange', [ '', 'Y' ])
+              || $self->ut_enum('password_recover',    [ '', 'Y' ])
+              || $self->ut_textn('cgp_accessmodes')
+              || $self->ut_alphan('cgp_type')
+              || $self->ut_textn('cgp_aliases' ) #well
+              #settings
+              || $self->ut_alphasn('cgp_rulesallowed')
+              || $self->ut_enum('cgp_rpopallowed', [ '', 'Y' ])
+              || $self->ut_enum('cgp_mailtoall', [ '', 'Y' ])
+              || $self->ut_enum('cgp_addmailtrailer', [ '', 'Y' ])
+              #preferences
+              || $self->ut_alphasn('cgp_deletemode')
+              || $self->ut_alphan('cgp_emptytrash')
+              || $self->ut_alphan('cgp_language')
+              || $self->ut_textn('cgp_timezone')
+              || $self->ut_textn('cgp_skinname')
+              || $self->ut_textn('cgp_prontoskinname')
+              || $self->ut_alphan('cgp_sendmdnmode')
+              #XXX vacation message, redirect all mail, mail rules
+              #XXX RPOP settings
   ;
   return $error if $error;
 
+  my $cust_pkg;
+  local $username_letter = $username_letter;
+  if ($self->svcnum) {
+    my $cust_svc = $self->cust_svc
+      or return "no cust_svc record found for svcnum ". $self->svcnum;
+    my $cust_pkg = $cust_svc->cust_pkg;
+  }
+  if ($self->pkgnum) {
+    $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $self->pkgnum } );#complain?
+  }
+  if ($cust_pkg) {
+    $username_letter =
+      $conf->exists('username-letter', $cust_pkg->cust_main->agentnum);
+  }
+
   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
   if ( $username_uppercase ) {
-    $recref->{username} =~ /^([a-z0-9_\-\.\&\%]{$usernamemin,$ulen})$/i
+    $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:]{$usernamemin,$ulen})$/i
       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
     $recref->{username} = $1;
   } else {
-    $recref->{username} =~ /^([a-z0-9_\-\.\&\%]{$usernamemin,$ulen})$/
+    $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:]{$usernamemin,$ulen})$/
       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
     $recref->{username} = $1;
   }
@@ -752,15 +1295,12 @@ sub check {
   unless ( $username_ampersand ) {
     $recref->{username} =~ /\&/ and return gettext('illegal_username');
   }
-  if ( $password_noampersand ) {
-    $recref->{_password} =~ /\&/ and return gettext('illegal_password');
-  }
-  if ( $password_noexclamation ) {
-    $recref->{_password} =~ /\!/ and return gettext('illegal_password');
-  }
   unless ( $username_percent ) {
     $recref->{username} =~ /\%/ and return gettext('illegal_username');
   }
+  unless ( $username_colon ) {
+    $recref->{username} =~ /\:/ and return gettext('illegal_username');
+  }
 
   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
   $recref->{popnum} = $1;
@@ -787,7 +1327,7 @@ sub check {
         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
       } else {
         return "Illegal shell \`". $self->shell. "\'; ".
-               $conf->dir. "/shells contains: @shells";
+               "shells configuration value contains: @shells";
       }
     } else {
       $recref->{shell} = '/bin/sync';
@@ -844,8 +1384,12 @@ sub check {
       or return "Illegal finger: ". $self->getfield('finger');
   $self->setfield('finger', $1);
 
-  $recref->{quota} =~ /^(\w*)$/ or return "Illegal quota";
-  $recref->{quota} = $1;
+  for (qw( quota file_quota file_maxsize )) {
+    $recref->{$_} =~ /^(\w*)$/ or return "Illegal $_";
+    $recref->{$_} = $1;
+  }
+  $recref->{file_maxnum} =~ /^\s*(\d*)\s*$/ or return "Illegal file_maxnum";
+  $recref->{file_maxnum} = $1;
 
   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
     if ( $recref->{slipip} eq '' ) {
@@ -865,36 +1409,203 @@ sub check {
     $self->ut_textn($_);
   }
 
-  #generate a password if it is blank
-  $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
-    unless ( $recref->{_password} );
-
-  #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
-  if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
-    $recref->{_password} = $1.$3;
-    #uncomment this to encrypt password immediately upon entry, or run
-    #bin/crypt_pw in cron to give new users a window during which their
-    #password is available to techs, for faxing, etc.  (also be aware of 
-    #radius issues!)
-    #$recref->{password} = $1.
-    #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
-    #;
-  } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;\+]{13,60})$/ ) {
-    $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";
-    return gettext('illegal_password'). " $passwordmin-$passwordmax ".
-           FS::Msgcat::_gettext('illegal_password_characters').
-           ": ". $recref->{_password};
+  # First, if _password is blank, generate one and set default encoding.
+  if ( ! $recref->{_password} ) {
+    $error = $self->set_password('');
   }
+  # But if there's a _password but no encoding, assume it's plaintext and 
+  # set it to default encoding.
+  elsif ( ! $recref->{_password_encoding} ) {
+    $error = $self->set_password($recref->{_password});
+  }
+  return $error if $error;
+
+  # Next, check _password to ensure compliance with the encoding.
+  if ( $recref->{_password_encoding} eq 'ldap' ) {
+
+    if ( $recref->{_password} =~ /^(\{[\w\-]+\})(!?.{0,64})$/ ) {
+      $recref->{_password} = uc($1).$2;
+    } else {
+      return 'Illegal (ldap-encoded) password: '. $recref->{_password};
+    }
+
+  } elsif ( $recref->{_password_encoding} eq 'crypt' ) {
+
+    if ( $recref->{_password} =~
+           #/^(\$\w+\$.*|[\w\+\/]{13}|_[\w\+\/]{19}|\*)$/
+           /^(!!?)?(\$\w+\$.*|[\w\+\/\.]{13}|_[\w\+\/\.]{19}|\*)$/
+       ) {
+
+      $recref->{_password} = ( defined($1) ? $1 : '' ). $2;
+
+    } else {
+      return 'Illegal (crypt-encoded) password: '. $recref->{_password};
+    }
+
+  } elsif ( $recref->{_password_encoding} eq 'plain' ) { 
+    # Password randomization is now in set_password.
+    # Strip whitespace characters, check length requirements, etc.
+    if ( $recref->{_password} =~ /^([^\t\n]{$passwordmin,$passwordmax})$/ ) {
+      $recref->{_password} = $1;
+    } else {
+      return gettext('illegal_password'). " $passwordmin-$passwordmax ".
+             FS::Msgcat::_gettext('illegal_password_characters').
+             ": ". $recref->{_password};
+    }
 
+    if ( $password_noampersand ) {
+      $recref->{_password} =~ /\&/ and return gettext('illegal_password');
+    }
+    if ( $password_noexclamation ) {
+      $recref->{_password} =~ /\!/ and return gettext('illegal_password');
+    }
+  }
+  else {
+    return "invalid password encoding ('".$recref->{_password_encoding}."'";
+  }
   $self->SUPER::check;
+
+}
+
+
+sub _password_encryption {
+  my $self = shift;
+  my $encoding = lc($self->_password_encoding);
+  return if !$encoding;
+  return 'plain' if $encoding eq 'plain';
+  if($encoding eq 'crypt') {
+    my $pass = $self->_password;
+    $pass =~ s/^\*SUSPENDED\* //;
+    $pass =~ s/^!!?//;
+    return 'md5' if $pass =~ /^\$1\$/;
+    #return 'blowfish' if $self->_password =~ /^\$2\$/;
+    return 'des' if length($pass) == 13;
+    return;
+  }
+  if($encoding eq 'ldap') {
+    uc($self->_password) =~ /^\{([\w-]+)\}/;
+    return 'crypt' if $1 eq 'CRYPT' or $1 eq 'DES';
+    return 'plain' if $1 eq 'PLAIN' or $1 eq 'CLEARTEXT';
+    return 'md5' if $1 eq 'MD5';
+    return 'sha1' if $1 eq 'SHA' or $1 eq 'SHA-1';
+
+    return;
+  }
+  return;
+}
+
+sub get_cleartext_password {
+  my $self = shift;
+  if($self->_password_encryption eq 'plain') {
+    if($self->_password_encoding eq 'ldap') {
+      $self->_password =~ /\{\w+\}(.*)$/;
+      return $1;
+    }
+    else {
+      return $self->_password;
+    }
+  }
+  return;
+}
+
+=item set_password
+
+Set the cleartext password for the account.  If _password_encoding is set, the 
+new password will be encoded according to the existing method (including 
+encryption mode, if it can be determined).  Otherwise, 
+config('default-password-encoding') is used.
+
+If no password is supplied (or a zero-length password when minimum password length 
+is >0), one will be generated randomly.
+
+=cut
+
+sub set_password {
+  my( $self, $pass ) = ( shift, shift );
+
+  warn "[$me] set_password (to $pass) called on $self: ". Dumper($self)
+     if $DEBUG;
+
+  my $failure = gettext('illegal_password'). " $passwordmin-$passwordmax ".
+                FS::Msgcat::_gettext('illegal_password_characters').
+                ": ". $pass;
+
+  my( $encoding, $encryption ) = ('', '');
+
+  if ( $self->_password_encoding ) {
+    $encoding = $self->_password_encoding;
+    # identify existing encryption method, try to use it.
+    $encryption = $self->_password_encryption;
+    if (!$encryption) {
+      # use the system default
+      undef $encoding;
+    }
+  }
+
+  if ( !$encoding ) {
+    # set encoding to system default
+    ($encoding, $encryption) =
+      split(/-/, lc($conf->config('default-password-encoding')));
+    $encoding ||= 'legacy';
+    $self->_password_encoding($encoding);
+  }
+
+  if ( $encoding eq 'legacy' ) {
+
+    # The legacy behavior from check():
+    # If the password is blank, randomize it and set encoding to 'plain'.
+    if(!defined($pass) or (length($pass) == 0 and $passwordmin)) {
+      $pass = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
+      $self->_password_encoding('plain');
+    } else {
+      # Prefix + valid-length password
+      if ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
+        $pass = $1.$3;
+        $self->_password_encoding('plain');
+      # Prefix + crypt string
+      } elsif ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([\w\.\/\$\;\+]{13,64})$/ ) {
+        $pass = $1.$3;
+        $self->_password_encoding('crypt');
+      # Various disabled crypt passwords
+      } elsif ( $pass eq '*' || $pass eq '!' || $pass eq '!!' ) {
+        $self->_password_encoding('crypt');
+      } else {
+        return $failure;
+      }
+    }
+
+    $self->_password($pass);
+    return;
+
+  }
+
+  return $failure
+    if $passwordmin && length($pass) < $passwordmin
+    or $passwordmax && length($pass) > $passwordmax;
+
+  if ( $encoding eq 'crypt' ) {
+    if ($encryption eq 'md5') {
+      $pass = unix_md5_crypt($pass);
+    } elsif ($encryption eq 'des') {
+      $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
+    }
+
+  } elsif ( $encoding eq 'ldap' ) {
+    if ($encryption eq 'md5') {
+      $pass = md5_base64($pass);
+    } elsif ($encryption eq 'sha1') {
+      $pass = sha1_base64($pass);
+    } elsif ($encryption eq 'crypt') {
+      $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
+    }
+    # else $encryption eq 'plain', do nothing
+    $pass = '{'.uc($encryption).'}'.$pass;
+  }
+  # else encoding eq 'plain'
+
+  $self->_password($pass);
+  return;
 }
 
 =item _check_system
@@ -914,7 +1625,7 @@ sub _check_system {
 
 =item _check_duplicate
 
-Internal function to check for duplicates usernames, username@domain pairs and
+Internal method to check for duplicates usernames, username@domain pairs and
 uids.
 
 If the I<global_unique-username> configuration value is set to B<username> or
@@ -931,12 +1642,7 @@ sub _check_duplicate {
   my $global_unique = $conf->config('global_unique-username') || 'none';
   return '' if $global_unique eq 'disabled';
 
-  #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;
+  $self->lock_table;
 
   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
   unless ( $part_svc ) {
@@ -1000,7 +1706,8 @@ sub _check_duplicate {
     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.
+        return "duplicate username ". $self->username.
+               ": conflicts with svcnum ". $dup_user->svcnum.
                " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
       }
     }
@@ -1008,9 +1715,9 @@ sub _check_duplicate {
     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};
+        return "duplicate username\@domain ". $self->email.
+               ": conflicts with svcnum ". $dup_userdomain->svcnum.
+               " via exportnum ". $conflict_userdomain_svcpart{$dup_svcpart};
       }
     }
 
@@ -1018,9 +1725,11 @@ sub _check_duplicate {
       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 "duplicate uid ". $self->uid.
+               ": conflicts with svcnum ". $dup_uid->svcnum.
+               " via exportnum ".
+               ( $conflict_user_svcpart{$dup_svcpart}
+                 || $conflict_userdomain_svcpart{$dup_svcpart} );
       }
     }
 
@@ -1074,6 +1783,29 @@ sub radius_reply {
     $reply{'Session-Timeout'} = $self->seconds;
   }
 
+  if ( $conf->exists('radius-chillispot-max') ) {
+    #http://dev.coova.org/svn/coova-chilli/doc/dictionary.chillispot
+
+    #hmm.  just because sqlradius.pm says so?
+    my %whatis = (
+      'input'  => 'up',
+      'output' => 'down',
+      'total'  => 'total',
+    );
+
+    foreach my $what (qw( input output total )) {
+      my $is = $whatis{$what}.'bytes';
+      if ( $self->$is() =~ /\d/ ) {
+        my $big = new Math::BigInt $self->$is();
+        $big = new Math::BigInt '0' if $big->is_neg();
+        my $att = "Chillispot-Max-\u$what";
+        $reply{"$att-Octets"}    = $big->copy->band(0xffffffff)->bstr;
+        $reply{"$att-Gigawords"} = $big->copy->brsft(32)->bstr;
+      }
+    }
+
+  }
+
   %reply;
 }
 
@@ -1102,21 +1834,53 @@ sub radius_check {
       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table );
 
-  my $password = $self->_password;
-  my $pw_attrib = length($password) <= 12 ? $radius_password : 'Crypt-Password';  $check{$pw_attrib} = $password;
+
+  my($pw_attrib, $password) = $self->radius_password;
+  $check{$pw_attrib} = $password;
 
   my $cust_svc = $self->cust_svc;
-  die "FATAL: no cust_svc record for svc_acct.svcnum ". $self->svcnum. "\n"
-    unless $cust_svc;
-  my $cust_pkg = $cust_svc->cust_pkg;
-  if ( $cust_pkg && $cust_pkg->part_pkg->is_prepaid && $cust_pkg->bill ) {
-    $check{'Expiration'} = time2str('%B %e %Y %T', $cust_pkg->bill ); #http://lists.cistron.nl/pipermail/freeradius-users/2005-January/040184.html
+  if ( $cust_svc ) {
+    my $cust_pkg = $cust_svc->cust_pkg;
+    if ( $cust_pkg && $cust_pkg->part_pkg->is_prepaid && $cust_pkg->bill ) {
+      $check{'Expiration'} = time2str('%B %e %Y %T', $cust_pkg->bill ); #http://lists.cistron.nl/pipermail/freeradius-users/2005-January/040184.html
+    }
+  } else {
+    warn "WARNING: no cust_svc record for svc_acct.svcnum ". $self->svcnum.
+         "; can't set Expiration\n"
+      unless $cust_svc;
   }
 
   %check;
 
 }
 
+=item radius_password 
+
+Returns a key/value pair containing the RADIUS attribute name and value
+for the password.
+
+=cut
+
+sub radius_password {
+  my $self = shift;
+
+  my $pw_attrib;
+  if ( $self->_password_encoding eq 'ldap' ) {
+    $pw_attrib = 'Password-With-Header';
+  } elsif ( $self->_password_encoding eq 'crypt' ) {
+    $pw_attrib = 'Crypt-Password';
+  } elsif ( $self->_password_encoding eq 'plain' ) {
+    $pw_attrib = $radius_password;
+  } else {
+    $pw_attrib = length($self->_password) <= 12
+                   ? $radius_password
+                   : 'Crypt-Password';
+  }
+
+  ($pw_attrib, $self->_password);
+
+}
+
 =item snapshot
 
 This method instructs the object to "snapshot" or freeze RADIUS check and
@@ -1152,10 +1916,13 @@ sub forget_snapshot {
 
 }
 
-=item domain
+=item domain [ END_TIMESTAMP [ START_TIMESTAMP ] ]
 
 Returns the domain associated with this account.
 
+END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
+history records.
+
 =cut
 
 sub domain {
@@ -1166,20 +1933,6 @@ sub domain {
   $svc_domain->domain;
 }
 
-=item svc_domain
-
-Returns the FS::svc_domain record for this account's domain (see
-L<FS::svc_domain>).
-
-=cut
-
-sub svc_domain {
-  my $self = shift;
-  $self->{'_domsvc'}
-    ? $self->{'_domsvc'}
-    : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
-}
-
 =item cust_svc
 
 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
@@ -1188,10 +1941,13 @@ Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
 
 #inherited from svc_Common
 
-=item email
+=item email [ END_TIMESTAMP [ START_TIMESTAMP ] ]
 
 Returns an email address associated with the account.
 
+END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
+history records.
+
 =cut
 
 sub email {
@@ -1215,85 +1971,233 @@ sub acct_snarf {
   qsearch('acct_snarf', { 'svcnum' => $self->svcnum } );
 }
 
-=item decrement_seconds SECONDS
+=item decrement_upbytes OCTETS
 
-Decrements the I<seconds> field of this record by the given amount.  If there
+Decrements the I<upbytes> field of this record by the given amount.  If there
 is an error, returns the error, otherwise returns false.
 
 =cut
 
-sub decrement_seconds {
-  shift->_op_seconds('-', @_);
+sub decrement_upbytes {
+  shift->_op_usage('-', 'upbytes', @_);
 }
 
-=item increment_seconds SECONDS
+=item increment_upbytes OCTETS
 
-Increments the I<seconds> field of this record by the given amount.  If there
+Increments the I<upbytes> field of this record by the given amount.  If there
 is an error, returns the error, otherwise returns false.
 
 =cut
 
-sub increment_seconds {
-  shift->_op_seconds('+', @_);
+sub increment_upbytes {
+  shift->_op_usage('+', 'upbytes', @_);
 }
 
+=item decrement_downbytes OCTETS
 
-my %op2action = (
-  '-' => 'suspend',
-  '+' => 'unsuspend',
-);
-my %op2condition = (
-  '-' => sub { my($self, $seconds) = @_;
-               $self->seconds - $seconds <= 0;
-             },
-  '+' => sub { my($self, $seconds) = @_;
-               $self->seconds + $seconds > 0;
-             },
-);
+Decrements the I<downbytes> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
 
-sub _op_seconds {
-  my( $self, $op, $seconds ) = @_;
-  warn "$me _op_seconds called for svcnum ". $self->svcnum.
-       ' ('. $self->email. "): $op $seconds\n"
-    if $DEBUG;
+=cut
 
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+sub decrement_downbytes {
+  shift->_op_usage('-', 'downbytes', @_);
+}
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+=item increment_downbytes OCTETS
 
-  my $sql = "UPDATE svc_acct SET seconds = ".
-            " CASE WHEN seconds IS NULL THEN 0 ELSE seconds END ". #$seconds||0
-            " $op ? WHERE svcnum = ?";
-  warn "$me $sql\n"
+Increments the I<downbytes> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub increment_downbytes {
+  shift->_op_usage('+', 'downbytes', @_);
+}
+
+=item decrement_totalbytes OCTETS
+
+Decrements the I<totalbytes> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub decrement_totalbytes {
+  shift->_op_usage('-', 'totalbytes', @_);
+}
+
+=item increment_totalbytes OCTETS
+
+Increments the I<totalbytes> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub increment_totalbytes {
+  shift->_op_usage('+', 'totalbytes', @_);
+}
+
+=item decrement_seconds SECONDS
+
+Decrements the I<seconds> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub decrement_seconds {
+  shift->_op_usage('-', 'seconds', @_);
+}
+
+=item increment_seconds SECONDS
+
+Increments the I<seconds> field of this record by the given amount.  If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub increment_seconds {
+  shift->_op_usage('+', 'seconds', @_);
+}
+
+
+my %op2action = (
+  '-' => 'suspend',
+  '+' => 'unsuspend',
+);
+my %op2condition = (
+  '-' => sub { my($self, $column, $amount) = @_;
+               $self->$column - $amount <= 0;
+             },
+  '+' => sub { my($self, $column, $amount) = @_;
+               ($self->$column || 0) + $amount > 0;
+             },
+);
+my %op2warncondition = (
+  '-' => sub { my($self, $column, $amount) = @_;
+               my $threshold = $column . '_threshold';
+               $self->$column - $amount <= $self->$threshold + 0;
+             },
+  '+' => sub { my($self, $column, $amount) = @_;
+               ($self->$column || 0) + $amount > 0;
+             },
+);
+
+sub _op_usage {
+  my( $self, $op, $column, $amount ) = @_;
+
+  warn "$me _op_usage called for $column on svcnum ". $self->svcnum.
+       ' ('. $self->email. "): $op $amount\n"
+    if $DEBUG;
+
+  return '' unless $amount;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $sql = "UPDATE svc_acct SET $column = ".
+            " CASE WHEN $column IS NULL THEN 0 ELSE $column END ". #$column||0
+            " $op ? WHERE svcnum = ?";
+  warn "$me $sql\n"
     if $DEBUG;
 
   my $sth = $dbh->prepare( $sql )
     or die "Error preparing $sql: ". $dbh->errstr;
-  my $rv = $sth->execute($seconds, $self->svcnum);
+  my $rv = $sth->execute($amount, $self->svcnum);
   die "Error executing $sql: ". $sth->errstr
     unless defined($rv);
-  die "Can't update seconds for svcnum". $self->svcnum
+  die "Can't update $column for svcnum". $self->svcnum
     if $rv == 0;
 
+  #$self->snapshot; #not necessary, we retain the old values
+  #create an object with the updated usage values
+  my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
+  #call exports
+  my $error = $new->replace($self);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "Error replacing: $error";
+  }
+
+  #overlimit_action eq 'cancel' handling
+  my $cust_pkg = $self->cust_svc->cust_pkg;
+  if ( $cust_pkg
+       && $cust_pkg->part_pkg->option('overlimit_action', 1) eq 'cancel' 
+       && $op eq '-' && &{$op2condition{$op}}($self, $column, $amount)
+     )
+  {
+
+    my $error = $cust_pkg->cancel; #XXX should have a reason
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error cancelling: $error";
+    }
+
+    #nothing else is relevant if we're cancelling, so commit & return success
+    warn "$me update successful; committing\n"
+      if $DEBUG;
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+
+  }
+
   my $action = $op2action{$op};
 
+  if ( &{$op2condition{$op}}($self, $column, $amount) &&
+        ( $action eq 'suspend'   && !$self->overlimit 
+       || $action eq 'unsuspend' &&  $self->overlimit ) 
+     ) {
+
+    my $error = $self->_op_overlimit($action);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
   if ( $conf->exists("svc_acct-usage_$action")
-       && &{$op2condition{$op}}($self, $seconds)    ) {
+       && &{$op2condition{$op}}($self, $column, $amount)    ) {
     #my $error = $self->$action();
     my $error = $self->cust_svc->cust_pkg->$action();
+    # $error ||= $self->overlimit($action);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error ${action}ing: $error";
     }
   }
 
+  if ($warning_template && &{$op2warncondition{$op}}($self, $column, $amount)) {
+    my $wqueue = new FS::queue {
+      'svcnum' => $self->svcnum,
+      'job'    => 'FS::svc_acct::reached_threshold',
+    };
+
+    my $to = '';
+    if ($op eq '-'){
+      $to = $warning_cc if &{$op2condition{$op}}($self, $column, $amount);
+    }
+
+    # x_threshold race
+    my $error = $wqueue->insert(
+      'svcnum' => $self->svcnum,
+      'op'     => $op,
+      'column' => $column,
+      'to'     => $to,
+    );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error queuing threshold activity: $error";
+    }
+  }
+
   warn "$me update successful; committing\n"
     if $DEBUG;
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -1301,6 +2205,202 @@ sub _op_seconds {
 
 }
 
+sub _op_overlimit {
+  my( $self, $action ) = @_;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_pkg = $self->cust_svc->cust_pkg;
+
+  my $conf_overlimit =
+    $cust_pkg
+      ? $conf->config('overlimit_groups', $cust_pkg->cust_main->agentnum )
+      : $conf->config('overlimit_groups');
+
+  foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
+
+    my $groups = $conf_overlimit || $part_export->option('overlimit_groups');
+    next unless $groups;
+
+    my $gref = &{ $self->_fieldhandlers->{'usergroup'} }( $self, $groups );
+
+    my $other = new FS::svc_acct $self->hashref;
+    $other->usergroup( $gref );
+
+    my($new,$old);
+    if ($action eq 'suspend') {
+      $new = $other;
+      $old = $self;
+    } else { # $action eq 'unsuspend'
+      $new = $self;
+      $old = $other;
+    }
+
+    my $error = $part_export->export_replace($new, $old)
+                || $self->overlimit($action);
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error replacing radius groups: $error";
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+sub set_usage {
+  my( $self, $valueref, %options ) = @_;
+
+  warn "$me set_usage called for svcnum ". $self->svcnum.
+       ' ('. $self->email. "): ".
+       join(', ', map { "$_ => " . $valueref->{$_}} keys %$valueref) . "\n"
+    if $DEBUG;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  local $FS::svc_Common::noexport_hack = 1;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $reset = 0;
+  my %handyhash = ();
+  if ( $options{null} ) { 
+    %handyhash = ( map { ( $_ => 'NULL', $_."_threshold" => 'NULL' ) }
+                   qw( seconds upbytes downbytes totalbytes )
+                 );
+  }
+  foreach my $field (keys %$valueref){
+    $reset = 1 if $valueref->{$field};
+    $self->setfield($field, $valueref->{$field});
+    $self->setfield( $field.'_threshold',
+                     int($self->getfield($field)
+                         * ( $conf->exists('svc_acct-usage_threshold') 
+                             ? 1 - $conf->config('svc_acct-usage_threshold')/100
+                             : 0.20
+                           )
+                       )
+                     );
+    $handyhash{$field} = $self->getfield($field);
+    $handyhash{$field.'_threshold'} = $self->getfield($field.'_threshold');
+  }
+  #my $error = $self->replace;   #NO! we avoid the call to ->check for
+  #die $error if $error;         #services not explicity changed via the UI
+
+  my $sql = "UPDATE svc_acct SET " .
+    join (',', map { "$_ =  $handyhash{$_}" } (keys %handyhash) ).
+    " WHERE svcnum = ". $self->svcnum;
+
+  warn "$me $sql\n"
+    if $DEBUG;
+
+  if (scalar(keys %handyhash)) {
+    my $sth = $dbh->prepare( $sql )
+      or die "Error preparing $sql: ". $dbh->errstr;
+    my $rv = $sth->execute();
+    die "Error executing $sql: ". $sth->errstr
+      unless defined($rv);
+    die "Can't update usage for svcnum ". $self->svcnum
+      if $rv == 0;
+  }
+
+  #$self->snapshot; #not necessary, we retain the old values
+  #create an object with the updated usage values
+  my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
+  local($FS::Record::nowarn_identical) = 1;
+  my $error = $new->replace($self); #call exports
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "Error replacing: $error";
+  }
+
+  if ( $reset ) {
+
+    my $error = '';
+
+    $error = $self->_op_overlimit('unsuspend')
+      if $self->overlimit;;
+
+    $error ||= $self->cust_svc->cust_pkg->unsuspend
+      if $conf->exists("svc_acct-usage_unsuspend");
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error unsuspending: $error";
+    }
+
+  }
+
+  warn "$me update successful; committing\n"
+    if $DEBUG;
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+
+=item recharge HASHREF
+
+  Increments usage columns by the amount specified in HASHREF as
+  column=>amount pairs.
+
+=cut
+
+sub recharge {
+  my ($self, $vhash) = @_;
+   
+  if ( $DEBUG ) {
+    warn "[$me] recharge called on $self: ". Dumper($self).
+         "\nwith vhash: ". Dumper($vhash);
+  }
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  my $error = '';
+
+  foreach my $column (keys %$vhash){
+    $error ||= $self->_op_usage('+', $column, $vhash->{$column});
+  }
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+  }else{
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  }
+  return $error;
+}
+
+=item is_rechargeable
+
+Returns true if this svc_account can be "recharged" and false otherwise.
+
+=cut
+
+sub is_rechargable {
+  my $self = shift;
+  $self->seconds ne ''
+    || $self->upbytes ne ''
+    || $self->downbytes ne ''
+    || $self->totalbytes ne '';
+}
 
 =item seconds_since TIMESTAMP
 
@@ -1372,6 +2472,17 @@ sub get_session_history {
   $self->cust_svc->get_session_history(@_);
 }
 
+=item last_login_text 
+
+Returns text describing the time of last login.
+
+=cut
+
+sub last_login_text {
+  my $self = shift;
+  $self->last_login ? ctime($self->last_login) : 'unknown';
+}
+
 =item get_cdrs TIMESTAMP_START TIMESTAMP_END [ 'OPTION' => 'VALUE ... ]
 
 =cut
@@ -1497,23 +2608,42 @@ sub check_password {
   #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;
+  if ( $self->_password_encoding eq 'ldap' ) {
+
+    my $auth = from_rfc2307 Authen::Passphrase $self->_password;
+    return $auth->match($check_password);
+
+  } elsif ( $self->_password_encoding eq 'crypt' ) {
+
+    my $auth = from_crypt Authen::Passphrase $self->_password;
+    return $auth->match($check_password);
+
+  } elsif ( $self->_password_encoding eq 'plain' ) {
+
+    return $check_password eq $password;
+
   } else {
-    warn "Can't check password: Unrecognized encryption for svcnum ".
-         $self->svcnum. "\n";
-    0;
+
+    #XXX this could be replaced with Authen::Passphrase stuff
+
+    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;
+    }
+
   }
 
 }
@@ -1534,14 +2664,40 @@ database.
 
 sub crypt_password {
   my $self = shift;
-  #eventually should check a "password-encoding" field
-  if ( length($self->_password) == 13
-       || $self->_password =~ /^\$(1|2a?)\$/
-       || $self->_password =~ /^(\*|NP|\*LK\*|!!?)$/
-     )
-  {
-    $self->_password;
-  } else {
+
+  if ( $self->_password_encoding eq 'ldap' ) {
+
+    if ( $self->_password =~ /^\{(PLAIN|CLEARTEXT)\}(.+)$/ ) {
+      my $plain = $2;
+
+      #XXX this could be replaced with Authen::Passphrase stuff
+
+      my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
+      if ( $encryption eq 'crypt' ) {
+        crypt(
+          $self->_password,
+          $saltset[int(rand(64))].$saltset[int(rand(64))]
+        );
+      } elsif ( $encryption eq 'md5' ) {
+        unix_md5_crypt( $self->_password );
+      } elsif ( $encryption eq 'blowfish' ) {
+        croak "unknown encryption method $encryption";
+      } else {
+        croak "unknown encryption method $encryption";
+      }
+
+    } elsif ( $self->_password =~ /^\{CRYPT\}(.+)$/ ) {
+      $1;
+    }
+
+  } elsif ( $self->_password_encoding eq 'crypt' ) {
+
+    return $self->_password;
+
+  } elsif ( $self->_password_encoding eq 'plain' ) {
+
+    #XXX this could be replaced with Authen::Passphrase stuff
+
     my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
     if ( $encryption eq 'crypt' ) {
       crypt(
@@ -1555,14 +2711,44 @@ sub crypt_password {
     } else {
       croak "unknown encryption method $encryption";
     }
+
+  } else {
+
+    if ( length($self->_password) == 13
+         || $self->_password =~ /^\$(1|2a?)\$/
+         || $self->_password =~ /^(\*|NP|\*LK\*|!!?)$/
+       )
+    {
+      $self->_password;
+    } else {
+    
+      #XXX this could be replaced with Authen::Passphrase stuff
+
+      my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
+      if ( $encryption eq 'crypt' ) {
+        crypt(
+          $self->_password,
+          $saltset[int(rand(64))].$saltset[int(rand(64))]
+        );
+      } elsif ( $encryption eq 'md5' ) {
+        unix_md5_crypt( $self->_password );
+      } elsif ( $encryption eq 'blowfish' ) {
+        croak "unknown encryption method $encryption";
+      } else {
+        croak "unknown encryption method $encryption";
+      }
+
+    }
+
   }
+
 }
 
 =item ldap_password [ DEFAULT_ENCRYPTION_TYPE ]
 
 Returns an encrypted password in "LDAP" format, with a curly-bracked prefix
-describing the format, for example, "{CRYPT}94pAVyK/4oIBk" or
-"{PLAIN-MD5}5426824942db4253f87a1009fd5d2d4f".
+describing the format, for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or
+"{MD5}5426824942db4253f87a1009fd5d2d4".
 
 The optional DEFAULT_ENCRYPTION_TYPE is not yet used, but the idea is for it
 to work the same as the B</crypt_password> method.
@@ -1572,29 +2758,71 @@ to work the same as the B</crypt_password> method.
 sub ldap_password {
   my $self = shift;
   #eventually should check a "password-encoding" field
-  if ( length($self->_password) == 13 ) { #crypt
-    return '{CRYPT}'. $self->_password;
-  } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
-    return '{MD5}'. $1;
-  } elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
-    die "Blowfish encryption not supported in this context, svcnum ".
-        $self->svcnum. "\n";
-  } else { #plaintext
+
+  if ( $self->_password_encoding eq 'ldap' ) {
+
+    return $self->_password;
+
+  } elsif ( $self->_password_encoding eq 'crypt' ) {
+
+    if ( length($self->_password) == 13 ) { #crypt
+      return '{CRYPT}'. $self->_password;
+    } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
+      return '{MD5}'. $1;
+    #} elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
+    #  die "Blowfish encryption not supported in this context, svcnum ".
+    #      $self->svcnum. "\n";
+    } else {
+      warn "encryption method not (yet?) supported in LDAP context";
+      return '{CRYPT}*'; #unsupported, should not auth
+    }
+
+  } elsif ( $self->_password_encoding eq 'plain' ) {
+
     return '{PLAIN}'. $self->_password;
-    #my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
-    #if ( $encryption eq 'crypt' ) {
-    #  return '{CRYPT}'. crypt(
-    #    $self->_password,
-    #    $saltset[int(rand(64))].$saltset[int(rand(64))]
-    #  );
-    #} elsif ( $encryption eq 'md5' ) {
-    #  unix_md5_crypt( $self->_password );
-    #} elsif ( $encryption eq 'blowfish' ) {
-    #  croak "unknown encryption method $encryption";
-    #} else {
-    #  croak "unknown encryption method $encryption";
-    #}
+
+    #return '{CLEARTEXT}'. $self->_password; #?
+
+  } else {
+
+    if ( length($self->_password) == 13 ) { #crypt
+      return '{CRYPT}'. $self->_password;
+    } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
+      return '{MD5}'. $1;
+    } elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
+      warn "Blowfish encryption not supported in this context, svcnum ".
+          $self->svcnum. "\n";
+      return '{CRYPT}*';
+
+    #are these two necessary anymore?
+    } elsif ( $self->_password =~ /^(\w{48})$/ ) { #LDAP SSHA
+      return '{SSHA}'. $1;
+    } elsif ( $self->_password =~ /^(\w{64})$/ ) { #LDAP NS-MTA-MD5
+      return '{NS-MTA-MD5}'. $1;
+
+    } else { #plaintext
+      return '{PLAIN}'. $self->_password;
+
+      #return '{CLEARTEXT}'. $self->_password; #?
+      
+      #XXX this could be replaced with Authen::Passphrase stuff if it gets used
+      #my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
+      #if ( $encryption eq 'crypt' ) {
+      #  return '{CRYPT}'. crypt(
+      #    $self->_password,
+      #    $saltset[int(rand(64))].$saltset[int(rand(64))]
+      #  );
+      #} elsif ( $encryption eq 'md5' ) {
+      #  unix_md5_crypt( $self->_password );
+      #} elsif ( $encryption eq 'blowfish' ) {
+      #  croak "unknown encryption method $encryption";
+      #} else {
+      #  croak "unknown encryption method $encryption";
+      #}
+    }
+
   }
+
 }
 
 =item domain_slash_username
@@ -1621,6 +2849,144 @@ sub virtual_maildir {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item search HASHREF
+
+Class method which returns a qsearch hash expression to search for parameters
+specified in HASHREF.  Valid parameters are
+
+=over 4
+
+=item domain
+
+=item domsvc
+
+=item unlinked
+
+=item agentnum
+
+=item pkgpart
+
+Arrayref of pkgparts
+
+=item pkgpart
+
+=item where
+
+Arrayref of additional WHERE clauses, will be ANDed together.
+
+=item order_by
+
+=item cust_fields
+
+=back
+
+=cut
+
+sub search {
+  my ($class, $params) = @_;
+
+  my @where = ();
+
+  # domain
+  if ( $params->{'domain'} ) { 
+    my $svc_domain = qsearchs('svc_domain', { 'domain'=>$params->{'domain'} } );
+    #preserve previous behavior & bubble up an error if $svc_domain not found?
+    push @where, 'domsvc = '. $svc_domain->svcnum if $svc_domain;
+  }
+
+  # domsvc
+  if ( $params->{'domsvc'} =~ /^(\d+)$/ ) { 
+    push @where, "domsvc = $1";
+  }
+
+  #unlinked
+  push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
+
+  #agentnum
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where, "agentnum = $1";
+  }
+
+  #custnum
+  if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where, "custnum = $1";
+  }
+
+  #pkgpart
+  if ( $params->{'pkgpart'} && scalar(@{ $params->{'pkgpart'} }) ) {
+    #XXX untaint or sql quote
+    push @where,
+      'cust_pkg.pkgpart IN ('. join(',', @{ $params->{'pkgpart'} } ). ')';
+  }
+
+  # popnum
+  if ( $params->{'popnum'} =~ /^(\d+)$/ ) { 
+    push @where, "popnum = $1";
+  }
+
+  # svcpart
+  if ( $params->{'svcpart'} =~ /^(\d+)$/ ) { 
+    push @where, "svcpart = $1";
+  }
+
+
+  # here is the agent virtualization
+  #if ($params->{CurrentUser}) {
+  #  my $access_user =
+  #    qsearchs('access_user', { username => $params->{CurrentUser} });
+  #
+  #  if ($access_user) {
+  #    push @where, $access_user->agentnums_sql('table'=>'cust_main');
+  #  }else{
+  #    push @where, "1=0";
+  #  }
+  #} else {
+    push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
+                   'table'      => 'cust_main',
+                   'null_right' => 'View/link unlinked services',
+                 );
+  #}
+
+  push @where, @{ $params->{'where'} } if $params->{'where'};
+
+  my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
+
+  my $addl_from = ' LEFT JOIN cust_svc  USING ( svcnum  ) '.
+                  ' LEFT JOIN part_svc  USING ( svcpart ) '.
+                  ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
+                  ' LEFT JOIN cust_main USING ( custnum ) ';
+
+  my $count_query = "SELECT COUNT(*) FROM svc_acct $addl_from $extra_sql";
+  #if ( keys %svc_acct ) {
+  #  $count_query .= ' WHERE '.
+  #                    join(' AND ', map "$_ = ". dbh->quote($svc_acct{$_}),
+  #                                      keys %svc_acct
+  #                        );
+  #}
+
+  my $sql_query = {
+    'table'       => 'svc_acct',
+    'hashref'     => {}, # \%svc_acct,
+    'select'      => join(', ',
+                       'svc_acct.*',
+                       'part_svc.svc',
+                       'cust_main.custnum',
+                       FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
+                     ),
+    'addl_from'   => $addl_from,
+    'extra_sql'   => $extra_sql,
+    'order_by'    => $params->{'order_by'},
+    'count_query' => $count_query,
+  };
+
+}
+
+=back
+
 =head1 SUBROUTINES
 
 =over 4
@@ -1656,7 +3022,7 @@ sub send_email {
 =cut
 
 sub check_and_rebuild_fuzzyfiles {
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   -e "$dir/svc_acct.username"
     or &rebuild_fuzzyfiles;
 }
@@ -1669,7 +3035,7 @@ sub rebuild_fuzzyfiles {
 
   use Fcntl qw(:flock);
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 
   #username
 
@@ -1695,7 +3061,7 @@ sub rebuild_fuzzyfiles {
 =cut
 
 sub all_username {
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  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>;
@@ -1714,7 +3080,7 @@ sub append_fuzzyfiles {
 
   use Fcntl qw(:flock);
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  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: $!";
@@ -1781,6 +3147,85 @@ END
   $html;
 }
 
+=item reached_threshold
+
+Performs some activities when svc_acct thresholds (such as number of seconds
+remaining) are reached.  
+
+=cut
+
+sub reached_threshold {
+  my %opt = @_;
+
+  my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $opt{'svcnum'} } );
+  die "Cannot find svc_acct with svcnum " . $opt{'svcnum'} unless $svc_acct;
+
+  if ( $opt{'op'} eq '+' ){
+    $svc_acct->setfield( $opt{'column'}.'_threshold',
+                         int($svc_acct->getfield($opt{'column'})
+                             * ( $conf->exists('svc_acct-usage_threshold') 
+                                 ? $conf->config('svc_acct-usage_threshold')/100
+                                 : 0.80
+                               )
+                         )
+                       );
+    my $error = $svc_acct->replace;
+    die $error if $error;
+  }elsif ( $opt{'op'} eq '-' ){
+    
+    my $threshold = $svc_acct->getfield( $opt{'column'}.'_threshold' );
+    return '' if ($threshold eq '' );
+
+    $svc_acct->setfield( $opt{'column'}.'_threshold', 0 );
+    my $error = $svc_acct->replace;
+    die $error if $error; # email next time, i guess
+
+    if ( $warning_template ) {
+      eval "use FS::Misc qw(send_email)";
+      die $@ if $@;
+
+      my $cust_pkg  = $svc_acct->cust_svc->cust_pkg;
+      my $cust_main = $cust_pkg->cust_main;
+
+      my $to = join(', ', grep { $_ !~ /^(POST|FAX)$/ } 
+                               $cust_main->invoicing_list,
+                               ($opt{'to'} ? $opt{'to'} : ())
+                   );
+
+      my $mimetype = $warning_mimetype;
+      $mimetype .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
+
+      my $body       =  $warning_template->fill_in( HASH => {
+                        'custnum'   => $cust_main->custnum,
+                        'username'  => $svc_acct->username,
+                        'password'  => $svc_acct->_password,
+                        'first'     => $cust_main->first,
+                        'last'      => $cust_main->getfield('last'),
+                        'pkg'       => $cust_pkg->part_pkg->pkg,
+                        'column'    => $opt{'column'},
+                        'amount'    => $opt{'column'} =~/bytes/
+                                       ? FS::UI::bytecount::display_bytecount($svc_acct->getfield($opt{'column'}))
+                                       : $svc_acct->getfield($opt{'column'}),
+                        'threshold' => $opt{'column'} =~/bytes/
+                                       ? FS::UI::bytecount::display_bytecount($threshold)
+                                       : $threshold,
+                      } );
+
+
+      my $error = send_email(
+        'from'         => $warning_from,
+        'to'           => $to,
+        'subject'      => $warning_subject,
+        'content-type' => $mimetype,
+        'body'         => [ map "$_\n", split("\n", $body) ],
+      );
+      die $error if $error;
+    }
+  }else{
+    die "unknown op: " . $opt{'op'};
+  }
+}
+
 =back
 
 =head1 BUGS
@@ -1797,6 +3242,8 @@ 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)
 
+_op_usage and set_usage bypass the history... maybe they shouldn't
+
 =head1 SEE ALSO
 
 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
@@ -1808,4 +3255,3 @@ schema.html from the base documentation.
 =cut
 
 1;
-