X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fsvc_acct.pm;h=509384170c4c9fb2f24b2ccbd6dddfea283e2677;hp=b201f23531ef0a1f9df6afbf91a1e609d62fad82;hb=7a8c7b6f64ac14d555d866f93300e64c47b74a98;hpb=97316d268e5751a1d08a0a37e5a0456f2ce4815c diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm index b201f2353..509384170 100644 --- a/FS/FS/svc_acct.pm +++ b/FS/FS/svc_acct.pm @@ -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) +=item popnum + +Point of presence (see L) =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 - I (reply) +=item pbxsvc -=item rc_I - I (check) +Optional svcnum from svc_pbx + +=item radius_I + +I (reply) + +=item rc_I + +I (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!POP number!, + '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 " +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. =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). 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 configuration value is set to B 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). - -=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). @@ -1188,10 +1941,13 @@ Returns the FS::cust_svc record for this account (see L). #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,383 @@ sub acct_snarf { qsearch('acct_snarf', { 'svcnum' => $self->svcnum } ); } -=item decrement_seconds SECONDS +=item decrement_upbytes OCTETS -Decrements the I field of this record by the given amount. If there +Decrements the I 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 field of this record by the given amount. If there +Increments the I 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 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 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 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 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 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 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; + ''; + +} + +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; @@ -1302,6 +2356,52 @@ sub _op_seconds { } +=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 Returns the number of seconds this account has been online 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( @@ -1551,11 +2707,133 @@ sub crypt_password { } elsif ( $encryption eq 'md5' ) { unix_md5_crypt( $self->_password ); } elsif ( $encryption eq 'blowfish' ) { - die "unknown encryption method $encryption"; + croak "unknown encryption method $encryption"; } else { - die "unknown encryption method $encryption"; + 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, "{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 method. + +=cut + +sub ldap_password { + my $self = shift; + #eventually should check a "password-encoding" field + + 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; + + #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 + +Returns $domain/$username/ + +=cut + +sub domain_slash_username { + my $self = shift; + $self->domain. '/'. $self->username. '/'; } =item virtual_maildir @@ -1571,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 @@ -1606,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; } @@ -1619,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 @@ -1645,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; $_; } ; @@ -1664,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: $!"; @@ -1731,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 @@ -1747,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, edit/part_svc.cgi from an installed web interface, @@ -1758,4 +3255,3 @@ schema.html from the base documentation. =cut 1; -