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