3bb7af4a686e9ae394fc8d87bbcd78fa5cba36f6
[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   unless ( $username_uppercase ) {
1186     $recref->{username} =~ /[A-Z]/ and return gettext('illegal_username');
1187   }
1188   if ( $username_letterfirst ) {
1189     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
1190   } elsif ( $username_letter ) {
1191     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
1192   }
1193   if ( $username_noperiod ) {
1194     $recref->{username} =~ /\./ and return gettext('illegal_username');
1195   }
1196   if ( $username_nounderscore ) {
1197     $recref->{username} =~ /_/ and return gettext('illegal_username');
1198   }
1199   if ( $username_nodash ) {
1200     $recref->{username} =~ /\-/ and return gettext('illegal_username');
1201   }
1202   unless ( $username_ampersand ) {
1203     $recref->{username} =~ /\&/ and return gettext('illegal_username');
1204   }
1205   unless ( $username_percent ) {
1206     $recref->{username} =~ /\%/ and return gettext('illegal_username');
1207   }
1208   unless ( $username_colon ) {
1209     $recref->{username} =~ /\:/ and return gettext('illegal_username');
1210   }
1211   unless ( $username_slash ) {
1212     $recref->{username} =~ /\// and return gettext('illegal_username');
1213   }
1214   unless ( $username_equals ) {
1215     $recref->{username} =~ /\=/ and return gettext('illegal_username');
1216   }
1217   unless ( $username_pound ) {
1218     $recref->{username} =~ /\#/ and return gettext('illegal_username');
1219   }
1220
1221
1222   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
1223   $recref->{popnum} = $1;
1224   return "Unknown popnum" unless
1225     ! $recref->{popnum} ||
1226     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
1227
1228   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
1229
1230     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
1231     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
1232
1233     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
1234     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
1235     #not all systems use gid=uid
1236     #you can set a fixed gid in part_svc
1237
1238     return "Only root can have uid 0"
1239       if $recref->{uid} == 0
1240          && $recref->{username} !~ /^(root|toor|smtp)$/;
1241
1242     unless ( $recref->{username} eq 'sync' ) {
1243       if ( grep $_ eq $recref->{shell}, @shells ) {
1244         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
1245       } else {
1246         return "Illegal shell \`". $self->shell. "\'; ".
1247                "shells configuration value contains: @shells";
1248       }
1249     } else {
1250       $recref->{shell} = '/bin/sync';
1251     }
1252
1253   } else {
1254     $recref->{gid} ne '' ? 
1255       return "Can't have gid without uid" : ( $recref->{gid}='' );
1256     #$recref->{dir} ne '' ? 
1257     #  return "Can't have directory without uid" : ( $recref->{dir}='' );
1258     $recref->{shell} ne '' ? 
1259       return "Can't have shell without uid" : ( $recref->{shell}='' );
1260   }
1261
1262   unless ( $part_svc->part_svc_column('dir')->columnflag eq 'F' ) {
1263
1264     $recref->{dir} =~ /^([\/\w\-\.\&\:\#]*)$/
1265       or return "Illegal directory: ". $recref->{dir};
1266     $recref->{dir} = $1;
1267     return "Illegal directory"
1268       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
1269     return "Illegal directory"
1270       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
1271     unless ( $recref->{dir} ) {
1272       $recref->{dir} = $dir_prefix . '/';
1273       if ( $dirhash > 0 ) {
1274         for my $h ( 1 .. $dirhash ) {
1275           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
1276         }
1277       } elsif ( $dirhash < 0 ) {
1278         for my $h ( reverse $dirhash .. -1 ) {
1279           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
1280         }
1281       }
1282       $recref->{dir} .= $recref->{username};
1283     ;
1284     }
1285
1286   }
1287
1288   #  $error = $self->ut_textn('finger');
1289   #  return $error if $error;
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   $self->getfield('finger') =~ /^([\w \,\.\-\'\&\t\!\@\#\$\%\(\)\+\;\"\?\/\*\<\>]+)$/
1300       or return "Illegal finger: ". $self->getfield('finger');
1301   $self->setfield('finger', $1);
1302
1303   for (qw( quota file_quota file_maxsize )) {
1304     $recref->{$_} =~ /^(\w*)$/ or return "Illegal $_";
1305     $recref->{$_} = $1;
1306   }
1307   $recref->{file_maxnum} =~ /^\s*(\d*)\s*$/ or return "Illegal file_maxnum";
1308   $recref->{file_maxnum} = $1;
1309
1310   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
1311     if ( $recref->{slipip} eq '' ) {
1312       $recref->{slipip} = '';
1313     } elsif ( $recref->{slipip} eq '0e0' ) {
1314       $recref->{slipip} = '0e0';
1315     } else {
1316       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
1317         or return "Illegal slipip: ". $self->slipip;
1318       $recref->{slipip} = $1;
1319     }
1320
1321   }
1322
1323   #arbitrary RADIUS stuff; allow ut_textn for now
1324   foreach ( grep /^radius_/, fields('svc_acct') ) {
1325     $self->ut_textn($_);
1326   }
1327
1328   # First, if _password is blank, generate one and set default encoding.
1329   if ( ! $recref->{_password} ) {
1330     $error = $self->set_password('');
1331   }
1332   # But if there's a _password but no encoding, assume it's plaintext and 
1333   # set it to default encoding.
1334   elsif ( ! $recref->{_password_encoding} ) {
1335     $error = $self->set_password($recref->{_password});
1336   }
1337   return $error if $error;
1338
1339   # Next, check _password to ensure compliance with the encoding.
1340   if ( $recref->{_password_encoding} eq 'ldap' ) {
1341
1342     if ( $recref->{_password} =~ /^(\{[\w\-]+\})(!?.{0,64})$/ ) {
1343       $recref->{_password} = uc($1).$2;
1344     } else {
1345       return 'Illegal (ldap-encoded) password: '. $recref->{_password};
1346     }
1347
1348   } elsif ( $recref->{_password_encoding} eq 'crypt' ) {
1349
1350     if ( $recref->{_password} =~
1351            #/^(\$\w+\$.*|[\w\+\/]{13}|_[\w\+\/]{19}|\*)$/
1352            /^(!!?)?(\$\w+\$.*|[\w\+\/\.]{13}|_[\w\+\/\.]{19}|\*)$/
1353        ) {
1354
1355       $recref->{_password} = ( defined($1) ? $1 : '' ). $2;
1356
1357     } else {
1358       return 'Illegal (crypt-encoded) password: '. $recref->{_password};
1359     }
1360
1361   } elsif ( $recref->{_password_encoding} eq 'plain' ) { 
1362     # Password randomization is now in set_password.
1363     # Strip whitespace characters, check length requirements, etc.
1364     if ( $recref->{_password} =~ /^([^\t\n]{$passwordmin,$passwordmax})$/ ) {
1365       $recref->{_password} = $1;
1366     } else {
1367       return gettext('illegal_password'). " $passwordmin-$passwordmax ".
1368              FS::Msgcat::_gettext('illegal_password_characters').
1369              ": ". $recref->{_password};
1370     }
1371
1372     if ( $password_noampersand ) {
1373       $recref->{_password} =~ /\&/ and return gettext('illegal_password');
1374     }
1375     if ( $password_noexclamation ) {
1376       $recref->{_password} =~ /\!/ and return gettext('illegal_password');
1377     }
1378   }
1379   else {
1380     return "invalid password encoding ('".$recref->{_password_encoding}."'";
1381   }
1382   $self->SUPER::check;
1383
1384 }
1385
1386
1387 sub _password_encryption {
1388   my $self = shift;
1389   my $encoding = lc($self->_password_encoding);
1390   return if !$encoding;
1391   return 'plain' if $encoding eq 'plain';
1392   if($encoding eq 'crypt') {
1393     my $pass = $self->_password;
1394     $pass =~ s/^\*SUSPENDED\* //;
1395     $pass =~ s/^!!?//;
1396     return 'md5' if $pass =~ /^\$1\$/;
1397     #return 'blowfish' if $self->_password =~ /^\$2\$/;
1398     return 'des' if length($pass) == 13;
1399     return;
1400   }
1401   if($encoding eq 'ldap') {
1402     uc($self->_password) =~ /^\{([\w-]+)\}/;
1403     return 'crypt' if $1 eq 'CRYPT' or $1 eq 'DES';
1404     return 'plain' if $1 eq 'PLAIN' or $1 eq 'CLEARTEXT';
1405     return 'md5' if $1 eq 'MD5';
1406     return 'sha1' if $1 eq 'SHA' or $1 eq 'SHA-1';
1407
1408     return;
1409   }
1410   return;
1411 }
1412
1413 sub get_cleartext_password {
1414   my $self = shift;
1415   if($self->_password_encryption eq 'plain') {
1416     if($self->_password_encoding eq 'ldap') {
1417       $self->_password =~ /\{\w+\}(.*)$/;
1418       return $1;
1419     }
1420     else {
1421       return $self->_password;
1422     }
1423   }
1424   return;
1425 }
1426
1427  
1428 =item set_password
1429
1430 Set the cleartext password for the account.  If _password_encoding is set, the 
1431 new password will be encoded according to the existing method (including 
1432 encryption mode, if it can be determined).  Otherwise, 
1433 config('default-password-encoding') is used.
1434
1435 If no password is supplied (or a zero-length password when minimum password length 
1436 is >0), one will be generated randomly.
1437
1438 =cut
1439
1440 sub set_password {
1441   my( $self, $pass ) = ( shift, shift );
1442
1443   warn "[$me] set_password (to $pass) called on $self: ". Dumper($self)
1444      if $DEBUG;
1445
1446   my $failure = gettext('illegal_password'). " $passwordmin-$passwordmax ".
1447                 FS::Msgcat::_gettext('illegal_password_characters').
1448                 ": ". $pass;
1449
1450   my( $encoding, $encryption ) = ('', '');
1451
1452   if ( $self->_password_encoding ) {
1453     $encoding = $self->_password_encoding;
1454     # identify existing encryption method, try to use it.
1455     $encryption = $self->_password_encryption;
1456     if (!$encryption) {
1457       # use the system default
1458       undef $encoding;
1459     }
1460   }
1461
1462   if ( !$encoding ) {
1463     # set encoding to system default
1464     ($encoding, $encryption) =
1465       split(/-/, lc($conf->config('default-password-encoding')));
1466     $encoding ||= 'legacy';
1467     $self->_password_encoding($encoding);
1468   }
1469
1470   if ( $encoding eq 'legacy' ) {
1471
1472     # The legacy behavior from check():
1473     # If the password is blank, randomize it and set encoding to 'plain'.
1474     if(!defined($pass) or (length($pass) == 0 and $passwordmin)) {
1475       $pass = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
1476       $self->_password_encoding('plain');
1477     } else {
1478       # Prefix + valid-length password
1479       if ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
1480         $pass = $1.$3;
1481         $self->_password_encoding('plain');
1482       # Prefix + crypt string
1483       } elsif ( $pass =~ /^((\*SUSPENDED\* |!!?)?)([\w\.\/\$\;\+]{13,64})$/ ) {
1484         $pass = $1.$3;
1485         $self->_password_encoding('crypt');
1486       # Various disabled crypt passwords
1487       } elsif ( $pass eq '*' || $pass eq '!' || $pass eq '!!' ) {
1488         $self->_password_encoding('crypt');
1489       } else {
1490         return $failure;
1491       }
1492     }
1493
1494     $self->_password($pass);
1495     return;
1496
1497   }
1498
1499   return $failure
1500     if $passwordmin && length($pass) < $passwordmin
1501     or $passwordmax && length($pass) > $passwordmax;
1502
1503   if ( $encoding eq 'crypt' ) {
1504     if ($encryption eq 'md5') {
1505       $pass = unix_md5_crypt($pass);
1506     } elsif ($encryption eq 'des') {
1507       $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
1508     }
1509
1510   } elsif ( $encoding eq 'ldap' ) {
1511     if ($encryption eq 'md5') {
1512       $pass = md5_base64($pass);
1513     } elsif ($encryption eq 'sha1') {
1514       $pass = sha1_base64($pass);
1515     } elsif ($encryption eq 'crypt') {
1516       $pass = crypt($pass, $saltset[int(rand(64))].$saltset[int(rand(64))]);
1517     }
1518     # else $encryption eq 'plain', do nothing
1519     $pass .= '=' x (4 - length($pass) % 4) #properly padded base64
1520       if $encryption eq 'md5' || $encryption eq 'sha1';
1521     $pass = '{'.uc($encryption).'}'.$pass;
1522   }
1523   # else encoding eq 'plain'
1524
1525   $self->_password($pass);
1526   return;
1527 }
1528
1529 =item _check_system
1530
1531 Internal function to check the username against the list of system usernames
1532 from the I<system_usernames> configuration value.  Returns true if the username
1533 is listed on the system username list.
1534
1535 =cut
1536
1537 sub _check_system {
1538   my $self = shift;
1539   scalar( grep { $self->username eq $_ || $self->email eq $_ }
1540                $conf->config('system_usernames')
1541         );
1542 }
1543
1544 =item _check_duplicate
1545
1546 Internal method to check for duplicates usernames, username@domain pairs and
1547 uids.
1548
1549 If the I<global_unique-username> configuration value is set to B<username> or
1550 B<username@domain>, enforces global username or username@domain uniqueness.
1551
1552 In all cases, check for duplicate uids and usernames or username@domain pairs
1553 per export and with identical I<svcpart> values.
1554
1555 =cut
1556
1557 sub _check_duplicate {
1558   my $self = shift;
1559
1560   my $global_unique = $conf->config('global_unique-username') || 'none';
1561   return '' if $global_unique eq 'disabled';
1562
1563   $self->lock_table;
1564
1565   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
1566   unless ( $part_svc ) {
1567     return 'unknown svcpart '. $self->svcpart;
1568   }
1569
1570   my @dup_user = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1571                  qsearch( 'svc_acct', { 'username' => $self->username } );
1572   return gettext('username_in_use')
1573     if $global_unique eq 'username' && @dup_user;
1574
1575   my @dup_userdomain = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1576                        qsearch( 'svc_acct', { 'username' => $self->username,
1577                                               'domsvc'   => $self->domsvc } );
1578   return gettext('username_in_use')
1579     if $global_unique eq 'username@domain' && @dup_userdomain;
1580
1581   my @dup_uid;
1582   if ( $part_svc->part_svc_column('uid')->columnflag ne 'F'
1583        && $self->username !~ /^(toor|(hyla)?fax)$/          ) {
1584     @dup_uid = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
1585                qsearch( 'svc_acct', { 'uid' => $self->uid } );
1586   } else {
1587     @dup_uid = ();
1588   }
1589
1590   if ( @dup_user || @dup_userdomain || @dup_uid ) {
1591     my $exports = FS::part_export::export_info('svc_acct');
1592     my %conflict_user_svcpart;
1593     my %conflict_userdomain_svcpart = ( $self->svcpart => 'SELF', );
1594
1595     foreach my $part_export ( $part_svc->part_export ) {
1596
1597       #this will catch to the same exact export
1598       my @svcparts = map { $_->svcpart } $part_export->export_svc;
1599
1600       #this will catch to exports w/same exporthost+type ???
1601       #my @other_part_export = qsearch('part_export', {
1602       #  'machine'    => $part_export->machine,
1603       #  'exporttype' => $part_export->exporttype,
1604       #} );
1605       #foreach my $other_part_export ( @other_part_export ) {
1606       #  push @svcparts, map { $_->svcpart }
1607       #    qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
1608       #}
1609
1610       #my $nodomain = $exports->{$part_export->exporttype}{'nodomain'};
1611       #silly kludge to avoid uninitialized value errors
1612       my $nodomain = exists( $exports->{$part_export->exporttype}{'nodomain'} )
1613                      ? $exports->{$part_export->exporttype}{'nodomain'}
1614                      : '';
1615       if ( $nodomain =~ /^Y/i ) {
1616         $conflict_user_svcpart{$_} = $part_export->exportnum
1617           foreach @svcparts;
1618       } else {
1619         $conflict_userdomain_svcpart{$_} = $part_export->exportnum
1620           foreach @svcparts;
1621       }
1622     }
1623
1624     foreach my $dup_user ( @dup_user ) {
1625       my $dup_svcpart = $dup_user->cust_svc->svcpart;
1626       if ( exists($conflict_user_svcpart{$dup_svcpart}) ) {
1627         return "duplicate username ". $self->username.
1628                ": conflicts with svcnum ". $dup_user->svcnum.
1629                " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
1630       }
1631     }
1632
1633     foreach my $dup_userdomain ( @dup_userdomain ) {
1634       my $dup_svcpart = $dup_userdomain->cust_svc->svcpart;
1635       if ( exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1636         return "duplicate username\@domain ". $self->email.
1637                ": conflicts with svcnum ". $dup_userdomain->svcnum.
1638                " via exportnum ". $conflict_userdomain_svcpart{$dup_svcpart};
1639       }
1640     }
1641
1642     foreach my $dup_uid ( @dup_uid ) {
1643       my $dup_svcpart = $dup_uid->cust_svc->svcpart;
1644       if ( exists($conflict_user_svcpart{$dup_svcpart})
1645            || exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1646         return "duplicate uid ". $self->uid.
1647                ": conflicts with svcnum ". $dup_uid->svcnum.
1648                " via exportnum ".
1649                ( $conflict_user_svcpart{$dup_svcpart}
1650                  || $conflict_userdomain_svcpart{$dup_svcpart} );
1651       }
1652     }
1653
1654   }
1655
1656   return '';
1657
1658 }
1659
1660 =item radius
1661
1662 Depriciated, use radius_reply instead.
1663
1664 =cut
1665
1666 sub radius {
1667   carp "FS::svc_acct::radius depriciated, use radius_reply";
1668   $_[0]->radius_reply;
1669 }
1670
1671 =item radius_reply
1672
1673 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1674 reply attributes of this record.
1675
1676 Note that this is now the preferred method for reading RADIUS attributes - 
1677 accessing the columns directly is discouraged, as the column names are
1678 expected to change in the future.
1679
1680 =cut
1681
1682 sub radius_reply { 
1683   my $self = shift;
1684
1685   return %{ $self->{'radius_reply'} }
1686     if exists $self->{'radius_reply'};
1687
1688   my %reply =
1689     map {
1690       /^(radius_(.*))$/;
1691       my($column, $attrib) = ($1, $2);
1692       #$attrib =~ s/_/\-/g;
1693       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1694     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
1695
1696   if ( $self->slipip && $self->slipip ne '0e0' ) {
1697     $reply{$radius_ip} = $self->slipip;
1698   }
1699
1700   if ( $self->seconds !~ /^$/ ) {
1701     $reply{'Session-Timeout'} = $self->seconds;
1702   }
1703
1704   if ( $conf->exists('radius-chillispot-max') ) {
1705     #http://dev.coova.org/svn/coova-chilli/doc/dictionary.chillispot
1706
1707     #hmm.  just because sqlradius.pm says so?
1708     my %whatis = (
1709       'input'  => 'up',
1710       'output' => 'down',
1711       'total'  => 'total',
1712     );
1713
1714     foreach my $what (qw( input output total )) {
1715       my $is = $whatis{$what}.'bytes';
1716       if ( $self->$is() =~ /\d/ ) {
1717         my $big = new Math::BigInt $self->$is();
1718         $big = new Math::BigInt '0' if $big->is_neg();
1719         my $att = "Chillispot-Max-\u$what";
1720         $reply{"$att-Octets"}    = $big->copy->band(0xffffffff)->bstr;
1721         $reply{"$att-Gigawords"} = $big->copy->brsft(32)->bstr;
1722       }
1723     }
1724
1725   }
1726
1727   %reply;
1728 }
1729
1730 =item radius_check
1731
1732 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1733 check attributes of this record.
1734
1735 Note that this is now the preferred method for reading RADIUS attributes - 
1736 accessing the columns directly is discouraged, as the column names are
1737 expected to change in the future.
1738
1739 =cut
1740
1741 sub radius_check {
1742   my $self = shift;
1743
1744   return %{ $self->{'radius_check'} }
1745     if exists $self->{'radius_check'};
1746
1747   my %check = 
1748     map {
1749       /^(rc_(.*))$/;
1750       my($column, $attrib) = ($1, $2);
1751       #$attrib =~ s/_/\-/g;
1752       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1753     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table );
1754
1755
1756   my($pw_attrib, $password) = $self->radius_password;
1757   $check{$pw_attrib} = $password;
1758
1759   my $cust_svc = $self->cust_svc;
1760   if ( $cust_svc ) {
1761     my $cust_pkg = $cust_svc->cust_pkg;
1762     if ( $cust_pkg && $cust_pkg->part_pkg->is_prepaid && $cust_pkg->bill ) {
1763       $check{'Expiration'} = time2str('%B %e %Y %T', $cust_pkg->bill ); #http://lists.cistron.nl/pipermail/freeradius-users/2005-January/040184.html
1764     }
1765   } else {
1766     warn "WARNING: no cust_svc record for svc_acct.svcnum ". $self->svcnum.
1767          "; can't set Expiration\n"
1768       unless $cust_svc;
1769   }
1770
1771   %check;
1772
1773 }
1774
1775 =item radius_password 
1776
1777 Returns a key/value pair containing the RADIUS attribute name and value
1778 for the password.
1779
1780 =cut
1781
1782 sub radius_password {
1783   my $self = shift;
1784
1785   my $pw_attrib;
1786   if ( $self->_password_encoding eq 'ldap' ) {
1787     $pw_attrib = 'Password-With-Header';
1788   } elsif ( $self->_password_encoding eq 'crypt' ) {
1789     $pw_attrib = 'Crypt-Password';
1790   } elsif ( $self->_password_encoding eq 'plain' ) {
1791     $pw_attrib = $radius_password;
1792   } else {
1793     $pw_attrib = length($self->_password) <= 12
1794                    ? $radius_password
1795                    : 'Crypt-Password';
1796   }
1797
1798   ($pw_attrib, $self->_password);
1799
1800 }
1801
1802 =item snapshot
1803
1804 This method instructs the object to "snapshot" or freeze RADIUS check and
1805 reply attributes to the current values.
1806
1807 =cut
1808
1809 #bah, my english is too broken this morning
1810 #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
1811 #the FS::cust_pkg's replace method to trigger the correct export updates when
1812 #package dates change)
1813
1814 sub snapshot {
1815   my $self = shift;
1816
1817   $self->{$_} = { $self->$_() }
1818     foreach qw( radius_reply radius_check );
1819
1820 }
1821
1822 =item forget_snapshot
1823
1824 This methos instructs the object to forget any previously snapshotted
1825 RADIUS check and reply attributes.
1826
1827 =cut
1828
1829 sub forget_snapshot {
1830   my $self = shift;
1831
1832   delete $self->{$_}
1833     foreach qw( radius_reply radius_check );
1834
1835 }
1836
1837 =item domain [ END_TIMESTAMP [ START_TIMESTAMP ] ]
1838
1839 Returns the domain associated with this account.
1840
1841 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
1842 history records.
1843
1844 =cut
1845
1846 sub domain {
1847   my $self = shift;
1848   die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
1849   my $svc_domain = $self->svc_domain(@_)
1850     or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
1851   $svc_domain->domain;
1852 }
1853
1854 =item cust_svc
1855
1856 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
1857
1858 =cut
1859
1860 #inherited from svc_Common
1861
1862 =item email [ END_TIMESTAMP [ START_TIMESTAMP ] ]
1863
1864 Returns an email address associated with the account.
1865
1866 END_TIMESTAMP and START_TIMESTAMP can optionally be passed when dealing with
1867 history records.
1868
1869 =cut
1870
1871 sub email {
1872   my $self = shift;
1873   $self->username. '@'. $self->domain(@_);
1874 }
1875
1876 =item acct_snarf
1877
1878 Returns an array of FS::acct_snarf records associated with the account.
1879
1880 =cut
1881
1882 sub acct_snarf {
1883   my $self = shift;
1884   qsearch({
1885     'table'    => 'acct_snarf',
1886     'hashref'  => { 'svcnum' => $self->svcnum },
1887     #'order_by' => 'ORDER BY priority ASC',
1888   });
1889 }
1890
1891 =item cgp_rpop_hashref
1892
1893 Returns an arrayref of RPOP data suitable for Communigate Pro API commands.
1894
1895 =cut
1896
1897 sub cgp_rpop_hashref {
1898   my $self = shift;
1899   { map { $_->snarfname => $_->cgp_hashref } $self->acct_snarf };
1900 }
1901
1902 =item decrement_upbytes OCTETS
1903
1904 Decrements the I<upbytes> field of this record by the given amount.  If there
1905 is an error, returns the error, otherwise returns false.
1906
1907 =cut
1908
1909 sub decrement_upbytes {
1910   shift->_op_usage('-', 'upbytes', @_);
1911 }
1912
1913 =item increment_upbytes OCTETS
1914
1915 Increments the I<upbytes> field of this record by the given amount.  If there
1916 is an error, returns the error, otherwise returns false.
1917
1918 =cut
1919
1920 sub increment_upbytes {
1921   shift->_op_usage('+', 'upbytes', @_);
1922 }
1923
1924 =item decrement_downbytes OCTETS
1925
1926 Decrements the I<downbytes> field of this record by the given amount.  If there
1927 is an error, returns the error, otherwise returns false.
1928
1929 =cut
1930
1931 sub decrement_downbytes {
1932   shift->_op_usage('-', 'downbytes', @_);
1933 }
1934
1935 =item increment_downbytes OCTETS
1936
1937 Increments the I<downbytes> field of this record by the given amount.  If there
1938 is an error, returns the error, otherwise returns false.
1939
1940 =cut
1941
1942 sub increment_downbytes {
1943   shift->_op_usage('+', 'downbytes', @_);
1944 }
1945
1946 =item decrement_totalbytes OCTETS
1947
1948 Decrements the I<totalbytes> field of this record by the given amount.  If there
1949 is an error, returns the error, otherwise returns false.
1950
1951 =cut
1952
1953 sub decrement_totalbytes {
1954   shift->_op_usage('-', 'totalbytes', @_);
1955 }
1956
1957 =item increment_totalbytes OCTETS
1958
1959 Increments the I<totalbytes> field of this record by the given amount.  If there
1960 is an error, returns the error, otherwise returns false.
1961
1962 =cut
1963
1964 sub increment_totalbytes {
1965   shift->_op_usage('+', 'totalbytes', @_);
1966 }
1967
1968 =item decrement_seconds SECONDS
1969
1970 Decrements the I<seconds> field of this record by the given amount.  If there
1971 is an error, returns the error, otherwise returns false.
1972
1973 =cut
1974
1975 sub decrement_seconds {
1976   shift->_op_usage('-', 'seconds', @_);
1977 }
1978
1979 =item increment_seconds SECONDS
1980
1981 Increments the I<seconds> field of this record by the given amount.  If there
1982 is an error, returns the error, otherwise returns false.
1983
1984 =cut
1985
1986 sub increment_seconds {
1987   shift->_op_usage('+', 'seconds', @_);
1988 }
1989
1990
1991 my %op2action = (
1992   '-' => 'suspend',
1993   '+' => 'unsuspend',
1994 );
1995 my %op2condition = (
1996   '-' => sub { my($self, $column, $amount) = @_;
1997                $self->$column - $amount <= 0;
1998              },
1999   '+' => sub { my($self, $column, $amount) = @_;
2000                ($self->$column || 0) + $amount > 0;
2001              },
2002 );
2003 my %op2warncondition = (
2004   '-' => sub { my($self, $column, $amount) = @_;
2005                my $threshold = $column . '_threshold';
2006                $self->$column - $amount <= $self->$threshold + 0;
2007              },
2008   '+' => sub { my($self, $column, $amount) = @_;
2009                ($self->$column || 0) + $amount > 0;
2010              },
2011 );
2012
2013 sub _op_usage {
2014   my( $self, $op, $column, $amount ) = @_;
2015
2016   warn "$me _op_usage called for $column on svcnum ". $self->svcnum.
2017        ' ('. $self->email. "): $op $amount\n"
2018     if $DEBUG;
2019
2020   return '' unless $amount;
2021
2022   local $SIG{HUP} = 'IGNORE';
2023   local $SIG{INT} = 'IGNORE';
2024   local $SIG{QUIT} = 'IGNORE';
2025   local $SIG{TERM} = 'IGNORE';
2026   local $SIG{TSTP} = 'IGNORE';
2027   local $SIG{PIPE} = 'IGNORE';
2028
2029   my $oldAutoCommit = $FS::UID::AutoCommit;
2030   local $FS::UID::AutoCommit = 0;
2031   my $dbh = dbh;
2032
2033   my $sql = "UPDATE svc_acct SET $column = ".
2034             " CASE WHEN $column IS NULL THEN 0 ELSE $column END ". #$column||0
2035             " $op ? WHERE svcnum = ?";
2036   warn "$me $sql\n"
2037     if $DEBUG;
2038
2039   my $sth = $dbh->prepare( $sql )
2040     or die "Error preparing $sql: ". $dbh->errstr;
2041   my $rv = $sth->execute($amount, $self->svcnum);
2042   die "Error executing $sql: ". $sth->errstr
2043     unless defined($rv);
2044   die "Can't update $column for svcnum". $self->svcnum
2045     if $rv == 0;
2046
2047   #$self->snapshot; #not necessary, we retain the old values
2048   #create an object with the updated usage values
2049   my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
2050   #call exports
2051   my $error = $new->replace($self);
2052   if ( $error ) {
2053     $dbh->rollback if $oldAutoCommit;
2054     return "Error replacing: $error";
2055   }
2056
2057   #overlimit_action eq 'cancel' handling
2058   my $cust_pkg = $self->cust_svc->cust_pkg;
2059   if ( $cust_pkg
2060        && $cust_pkg->part_pkg->option('overlimit_action', 1) eq 'cancel' 
2061        && $op eq '-' && &{$op2condition{$op}}($self, $column, $amount)
2062      )
2063   {
2064
2065     my $error = $cust_pkg->cancel; #XXX should have a reason
2066     if ( $error ) {
2067       $dbh->rollback if $oldAutoCommit;
2068       return "Error cancelling: $error";
2069     }
2070
2071     #nothing else is relevant if we're cancelling, so commit & return success
2072     warn "$me update successful; committing\n"
2073       if $DEBUG;
2074     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2075     return '';
2076
2077   }
2078
2079   my $action = $op2action{$op};
2080
2081   if ( &{$op2condition{$op}}($self, $column, $amount) &&
2082         ( $action eq 'suspend'   && !$self->overlimit 
2083        || $action eq 'unsuspend' &&  $self->overlimit ) 
2084      ) {
2085
2086     my $error = $self->_op_overlimit($action);
2087     if ( $error ) {
2088       $dbh->rollback if $oldAutoCommit;
2089       return $error;
2090     }
2091
2092   }
2093
2094   if ( $conf->exists("svc_acct-usage_$action")
2095        && &{$op2condition{$op}}($self, $column, $amount)    ) {
2096     #my $error = $self->$action();
2097     my $error = $self->cust_svc->cust_pkg->$action();
2098     # $error ||= $self->overlimit($action);
2099     if ( $error ) {
2100       $dbh->rollback if $oldAutoCommit;
2101       return "Error ${action}ing: $error";
2102     }
2103   }
2104
2105   if ($warning_template && &{$op2warncondition{$op}}($self, $column, $amount)) {
2106     my $wqueue = new FS::queue {
2107       'svcnum' => $self->svcnum,
2108       'job'    => 'FS::svc_acct::reached_threshold',
2109     };
2110
2111     my $to = '';
2112     if ($op eq '-'){
2113       $to = $warning_cc if &{$op2condition{$op}}($self, $column, $amount);
2114     }
2115
2116     # x_threshold race
2117     my $error = $wqueue->insert(
2118       'svcnum' => $self->svcnum,
2119       'op'     => $op,
2120       'column' => $column,
2121       'to'     => $to,
2122     );
2123     if ( $error ) {
2124       $dbh->rollback if $oldAutoCommit;
2125       return "Error queuing threshold activity: $error";
2126     }
2127   }
2128
2129   warn "$me update successful; committing\n"
2130     if $DEBUG;
2131   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2132   '';
2133
2134 }
2135
2136 sub _op_overlimit {
2137   my( $self, $action ) = @_;
2138
2139   local $SIG{HUP} = 'IGNORE';
2140   local $SIG{INT} = 'IGNORE';
2141   local $SIG{QUIT} = 'IGNORE';
2142   local $SIG{TERM} = 'IGNORE';
2143   local $SIG{TSTP} = 'IGNORE';
2144   local $SIG{PIPE} = 'IGNORE';
2145
2146   my $oldAutoCommit = $FS::UID::AutoCommit;
2147   local $FS::UID::AutoCommit = 0;
2148   my $dbh = dbh;
2149
2150   my $cust_pkg = $self->cust_svc->cust_pkg;
2151
2152   my @conf_overlimit =
2153     $cust_pkg
2154       ? $conf->config('overlimit_groups', $cust_pkg->cust_main->agentnum )
2155       : $conf->config('overlimit_groups');
2156
2157   foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
2158
2159     my @groups = scalar(@conf_overlimit) ? @conf_overlimit
2160                                          : split(' ',$part_export->option('overlimit_groups'));
2161     next unless scalar(@groups);
2162
2163     my $other = new FS::svc_acct $self->hashref;
2164     $other->usergroup(\@groups);
2165
2166     my($new,$old);
2167     if ($action eq 'suspend') {
2168       $new = $other;
2169       $old = $self;
2170     } else { # $action eq 'unsuspend'
2171       $new = $self;
2172       $old = $other;
2173     }
2174
2175     my $error = $part_export->export_replace($new, $old)
2176                 || $self->overlimit($action);
2177
2178     if ( $error ) {
2179       $dbh->rollback if $oldAutoCommit;
2180       return "Error replacing radius groups: $error";
2181     }
2182
2183   }
2184
2185   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2186   '';
2187
2188 }
2189
2190 sub set_usage {
2191   my( $self, $valueref, %options ) = @_;
2192
2193   warn "$me set_usage called for svcnum ". $self->svcnum.
2194        ' ('. $self->email. "): ".
2195        join(', ', map { "$_ => " . $valueref->{$_}} keys %$valueref) . "\n"
2196     if $DEBUG;
2197
2198   local $SIG{HUP} = 'IGNORE';
2199   local $SIG{INT} = 'IGNORE';
2200   local $SIG{QUIT} = 'IGNORE';
2201   local $SIG{TERM} = 'IGNORE';
2202   local $SIG{TSTP} = 'IGNORE';
2203   local $SIG{PIPE} = 'IGNORE';
2204
2205   local $FS::svc_Common::noexport_hack = 1;
2206   my $oldAutoCommit = $FS::UID::AutoCommit;
2207   local $FS::UID::AutoCommit = 0;
2208   my $dbh = dbh;
2209
2210   my $reset = 0;
2211   my %handyhash = ();
2212   if ( $options{null} ) { 
2213     %handyhash = ( map { ( $_ => undef, $_."_threshold" => undef ) }
2214                    qw( seconds upbytes downbytes totalbytes )
2215                  );
2216   }
2217   foreach my $field (keys %$valueref){
2218     $reset = 1 if $valueref->{$field};
2219     $self->setfield($field, $valueref->{$field});
2220     $self->setfield( $field.'_threshold',
2221                      int($self->getfield($field)
2222                          * ( $conf->exists('svc_acct-usage_threshold') 
2223                              ? 1 - $conf->config('svc_acct-usage_threshold')/100
2224                              : 0.20
2225                            )
2226                        )
2227                      );
2228     $handyhash{$field} = $self->getfield($field);
2229     $handyhash{$field.'_threshold'} = $self->getfield($field.'_threshold');
2230   }
2231   #my $error = $self->replace;   #NO! we avoid the call to ->check for
2232   #die $error if $error;         #services not explicity changed via the UI
2233
2234   my $sql = "UPDATE svc_acct SET " .
2235     join (',', map { "$_ =  ?" } (keys %handyhash) ).
2236     " WHERE svcnum = ". $self->svcnum;
2237
2238   warn "$me $sql\n"
2239     if $DEBUG;
2240
2241   if (scalar(keys %handyhash)) {
2242     my $sth = $dbh->prepare( $sql )
2243       or die "Error preparing $sql: ". $dbh->errstr;
2244     my $rv = $sth->execute(values %handyhash);
2245     die "Error executing $sql: ". $sth->errstr
2246       unless defined($rv);
2247     die "Can't update usage for svcnum ". $self->svcnum
2248       if $rv == 0;
2249   }
2250
2251   #$self->snapshot; #not necessary, we retain the old values
2252   #create an object with the updated usage values
2253   my $new = qsearchs('svc_acct', { 'svcnum' => $self->svcnum });
2254   local($FS::Record::nowarn_identical) = 1;
2255   my $error = $new->replace($self); #call exports
2256   if ( $error ) {
2257     $dbh->rollback if $oldAutoCommit;
2258     return "Error replacing: $error";
2259   }
2260
2261   if ( $reset ) {
2262
2263     my $error = '';
2264
2265     $error = $self->_op_overlimit('unsuspend')
2266       if $self->overlimit;;
2267
2268     $error ||= $self->cust_svc->cust_pkg->unsuspend
2269       if $conf->exists("svc_acct-usage_unsuspend");
2270
2271     if ( $error ) {
2272       $dbh->rollback if $oldAutoCommit;
2273       return "Error unsuspending: $error";
2274     }
2275
2276   }
2277
2278   warn "$me update successful; committing\n"
2279     if $DEBUG;
2280   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2281   '';
2282
2283 }
2284
2285
2286 =item recharge HASHREF
2287
2288   Increments usage columns by the amount specified in HASHREF as
2289   column=>amount pairs.
2290
2291 =cut
2292
2293 sub recharge {
2294   my ($self, $vhash) = @_;
2295    
2296   if ( $DEBUG ) {
2297     warn "[$me] recharge called on $self: ". Dumper($self).
2298          "\nwith vhash: ". Dumper($vhash);
2299   }
2300
2301   my $oldAutoCommit = $FS::UID::AutoCommit;
2302   local $FS::UID::AutoCommit = 0;
2303   my $dbh = dbh;
2304   my $error = '';
2305
2306   foreach my $column (keys %$vhash){
2307     $error ||= $self->_op_usage('+', $column, $vhash->{$column});
2308   }
2309
2310   if ( $error ) {
2311     $dbh->rollback if $oldAutoCommit;
2312   }else{
2313     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2314   }
2315   return $error;
2316 }
2317
2318 =item is_rechargeable
2319
2320 Returns true if this svc_account can be "recharged" and false otherwise.
2321
2322 =cut
2323
2324 sub is_rechargable {
2325   my $self = shift;
2326   $self->seconds ne ''
2327     || $self->upbytes ne ''
2328     || $self->downbytes ne ''
2329     || $self->totalbytes ne '';
2330 }
2331
2332 =item seconds_since TIMESTAMP
2333
2334 Returns the number of seconds this account has been online since TIMESTAMP,
2335 according to the session monitor (see L<FS::Session>).
2336
2337 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2338 L<Time::Local> and L<Date::Parse> for conversion functions.
2339
2340 =cut
2341
2342 #note: POD here, implementation in FS::cust_svc
2343 sub seconds_since {
2344   my $self = shift;
2345   $self->cust_svc->seconds_since(@_);
2346 }
2347
2348 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2349
2350 Returns the numbers of seconds this account has been online between
2351 TIMESTAMP_START (inclusive) and TIMESTAMP_END (exclusive), according to an
2352 external SQL radacct table, specified via sqlradius export.  Sessions which
2353 started in the specified range but are still open are counted from session
2354 start to the end of the range (unless they are over 1 day old, in which case
2355 they are presumed missing their stop record and not counted).  Also, sessions
2356 which end in the range but started earlier are counted from the start of the
2357 range to session end.  Finally, sessions which start before the range but end
2358 after are counted for the entire range.
2359
2360 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2361 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2362 functions.
2363
2364 =cut
2365
2366 #note: POD here, implementation in FS::cust_svc
2367 sub seconds_since_sqlradacct {
2368   my $self = shift;
2369   $self->cust_svc->seconds_since_sqlradacct(@_);
2370 }
2371
2372 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2373
2374 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2375 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2376 TIMESTAMP_END (exclusive).
2377
2378 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2379 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2380 functions.
2381
2382 =cut
2383
2384 #note: POD here, implementation in FS::cust_svc
2385 sub attribute_since_sqlradacct {
2386   my $self = shift;
2387   $self->cust_svc->attribute_since_sqlradacct(@_);
2388 }
2389
2390 =item get_session_history TIMESTAMP_START TIMESTAMP_END
2391
2392 Returns an array of hash references of this customers login history for the
2393 given time range.  (document this better)
2394
2395 =cut
2396
2397 sub get_session_history {
2398   my $self = shift;
2399   $self->cust_svc->get_session_history(@_);
2400 }
2401
2402 =item last_login_text 
2403
2404 Returns text describing the time of last login.
2405
2406 =cut
2407
2408 sub last_login_text {
2409   my $self = shift;
2410   $self->last_login ? ctime($self->last_login) : 'unknown';
2411 }
2412
2413 =item get_cdrs TIMESTAMP_START TIMESTAMP_END [ 'OPTION' => 'VALUE ... ]
2414
2415 =cut
2416
2417 sub get_cdrs {
2418   my($self, $start, $end, %opt ) = @_;
2419
2420   my $did = $self->username; #yup
2421
2422   my $prefix = $opt{'default_prefix'}; #convergent.au '+61'
2423
2424   my $for_update = $opt{'for_update'} ? 'FOR UPDATE' : '';
2425
2426   #SELECT $for_update * FROM cdr
2427   #  WHERE calldate >= $start #need a conversion
2428   #    AND calldate <  $end   #ditto
2429   #    AND (    charged_party = "$did"
2430   #          OR charged_party = "$prefix$did" #if length($prefix);
2431   #          OR ( ( charged_party IS NULL OR charged_party = '' )
2432   #               AND
2433   #               ( src = "$did" OR src = "$prefix$did" ) # if length($prefix)
2434   #             )
2435   #        )
2436   #    AND ( freesidestatus IS NULL OR freesidestatus = '' )
2437
2438   my $charged_or_src;
2439   if ( length($prefix) ) {
2440     $charged_or_src =
2441       " AND (    charged_party = '$did' 
2442               OR charged_party = '$prefix$did'
2443               OR ( ( charged_party IS NULL OR charged_party = '' )
2444                    AND
2445                    ( src = '$did' OR src = '$prefix$did' )
2446                  )
2447             )
2448       ";
2449   } else {
2450     $charged_or_src = 
2451       " AND (    charged_party = '$did' 
2452               OR ( ( charged_party IS NULL OR charged_party = '' )
2453                    AND
2454                    src = '$did'
2455                  )
2456             )
2457       ";
2458
2459   }
2460
2461   qsearch(
2462     'select'    => "$for_update *",
2463     'table'     => 'cdr',
2464     'hashref'   => {
2465                      #( freesidestatus IS NULL OR freesidestatus = '' )
2466                      'freesidestatus' => '',
2467                    },
2468     'extra_sql' => $charged_or_src,
2469
2470   );
2471
2472 }
2473
2474 # sub radius_groups has moved to svc_Radius_Mixin
2475
2476 =item clone_suspended
2477
2478 Constructor used by FS::part_export::_export_suspend fallback.  Document
2479 better.
2480
2481 =cut
2482
2483 sub clone_suspended {
2484   my $self = shift;
2485   my %hash = $self->hash;
2486   $hash{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
2487   new FS::svc_acct \%hash;
2488 }
2489
2490 =item clone_kludge_unsuspend 
2491
2492 Constructor used by FS::part_export::_export_unsuspend fallback.  Document
2493 better.
2494
2495 =cut
2496
2497 sub clone_kludge_unsuspend {
2498   my $self = shift;
2499   my %hash = $self->hash;
2500   $hash{_password} = '';
2501   new FS::svc_acct \%hash;
2502 }
2503
2504 =item check_password 
2505
2506 Checks the supplied password against the (possibly encrypted) password in the
2507 database.  Returns true for a successful authentication, false for no match.
2508
2509 Currently supported encryptions are: classic DES crypt() and MD5
2510
2511 =cut
2512
2513 sub check_password {
2514   my($self, $check_password) = @_;
2515
2516   #remove old-style SUSPENDED kludge, they should be allowed to login to
2517   #self-service and pay up
2518   ( my $password = $self->_password ) =~ s/^\*SUSPENDED\* //;
2519
2520   if ( $self->_password_encoding eq 'ldap' ) {
2521
2522     my $auth = from_rfc2307 Authen::Passphrase $self->_password;
2523     return $auth->match($check_password);
2524
2525   } elsif ( $self->_password_encoding eq 'crypt' ) {
2526
2527     my $auth = from_crypt Authen::Passphrase $self->_password;
2528     return $auth->match($check_password);
2529
2530   } elsif ( $self->_password_encoding eq 'plain' ) {
2531
2532     return $check_password eq $password;
2533
2534   } else {
2535
2536     #XXX this could be replaced with Authen::Passphrase stuff
2537
2538     if ( $password =~ /^(\*|!!?)$/ ) { #no self-service login
2539       return 0;
2540     } elsif ( length($password) < 13 ) { #plaintext
2541       $check_password eq $password;
2542     } elsif ( length($password) == 13 ) { #traditional DES crypt
2543       crypt($check_password, $password) eq $password;
2544     } elsif ( $password =~ /^\$1\$/ ) { #MD5 crypt
2545       unix_md5_crypt($check_password, $password) eq $password;
2546     } elsif ( $password =~ /^\$2a?\$/ ) { #Blowfish
2547       warn "Can't check password: Blowfish encryption not yet supported, ".
2548            "svcnum ".  $self->svcnum. "\n";
2549       0;
2550     } else {
2551       warn "Can't check password: Unrecognized encryption for svcnum ".
2552            $self->svcnum. "\n";
2553       0;
2554     }
2555
2556   }
2557
2558 }
2559
2560 =item crypt_password [ DEFAULT_ENCRYPTION_TYPE ]
2561
2562 Returns an encrypted password, either by passing through an encrypted password
2563 in the database or by encrypting a plaintext password from the database.
2564
2565 The optional DEFAULT_ENCRYPTION_TYPE parameter can be set to I<crypt> (classic
2566 UNIX DES crypt), I<md5> (md5 crypt supported by most modern Linux and BSD
2567 distrubtions), or (eventually) I<blowfish> (blowfish hashing supported by
2568 OpenBSD, SuSE, other Linux distibutions with pam_unix2, etc.).  The default
2569 encryption type is only used if the password is not already encrypted in the
2570 database.
2571
2572 =cut
2573
2574 sub crypt_password {
2575   my $self = shift;
2576
2577   if ( $self->_password_encoding eq 'ldap' ) {
2578
2579     if ( $self->_password =~ /^\{(PLAIN|CLEARTEXT)\}(.+)$/ ) {
2580       my $plain = $2;
2581
2582       #XXX this could be replaced with Authen::Passphrase stuff
2583
2584       my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2585       if ( $encryption eq 'crypt' ) {
2586         return crypt(
2587           $self->_password,
2588           $saltset[int(rand(64))].$saltset[int(rand(64))]
2589         );
2590       } elsif ( $encryption eq 'md5' ) {
2591         return unix_md5_crypt( $self->_password );
2592       } elsif ( $encryption eq 'blowfish' ) {
2593         croak "unknown encryption method $encryption";
2594       } else {
2595         croak "unknown encryption method $encryption";
2596       }
2597
2598     } elsif ( $self->_password =~ /^\{CRYPT\}(.+)$/ ) {
2599       return $1;
2600     }
2601
2602   } elsif ( $self->_password_encoding eq 'crypt' ) {
2603
2604     return $self->_password;
2605
2606   } elsif ( $self->_password_encoding eq 'plain' ) {
2607
2608     #XXX this could be replaced with Authen::Passphrase stuff
2609
2610     my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2611     if ( $encryption eq 'crypt' ) {
2612       return crypt(
2613         $self->_password,
2614         $saltset[int(rand(64))].$saltset[int(rand(64))]
2615       );
2616     } elsif ( $encryption eq 'md5' ) {
2617       return unix_md5_crypt( $self->_password );
2618     } elsif ( $encryption eq 'sha1_base64' ) { #for acct_sql
2619       my $pass = sha1_base64( $self->_password );
2620       $pass .= '=' x (4 - length($pass) % 4); #properly padded base64
2621       return $pass;
2622     } elsif ( $encryption eq 'blowfish' ) {
2623       croak "unknown encryption method $encryption";
2624     } else {
2625       croak "unknown encryption method $encryption";
2626     }
2627
2628   } else {
2629
2630     if ( length($self->_password) == 13
2631          || $self->_password =~ /^\$(1|2a?)\$/
2632          || $self->_password =~ /^(\*|NP|\*LK\*|!!?)$/
2633        )
2634     {
2635       $self->_password;
2636     } else {
2637     
2638       #XXX this could be replaced with Authen::Passphrase stuff
2639
2640       my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2641       if ( $encryption eq 'crypt' ) {
2642         return crypt(
2643           $self->_password,
2644           $saltset[int(rand(64))].$saltset[int(rand(64))]
2645         );
2646       } elsif ( $encryption eq 'md5' ) {
2647         return unix_md5_crypt( $self->_password );
2648       } elsif ( $encryption eq 'blowfish' ) {
2649         croak "unknown encryption method $encryption";
2650       } else {
2651         croak "unknown encryption method $encryption";
2652       }
2653
2654     }
2655
2656   }
2657
2658 }
2659
2660 =item ldap_password [ DEFAULT_ENCRYPTION_TYPE ]
2661
2662 Returns an encrypted password in "LDAP" format, with a curly-bracked prefix
2663 describing the format, for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or
2664 "{MD5}5426824942db4253f87a1009fd5d2d4".
2665
2666 The optional DEFAULT_ENCRYPTION_TYPE is not yet used, but the idea is for it
2667 to work the same as the B</crypt_password> method.
2668
2669 =cut
2670
2671 sub ldap_password {
2672   my $self = shift;
2673   #eventually should check a "password-encoding" field
2674
2675   if ( $self->_password_encoding eq 'ldap' ) {
2676
2677     return $self->_password;
2678
2679   } elsif ( $self->_password_encoding eq 'crypt' ) {
2680
2681     if ( length($self->_password) == 13 ) { #crypt
2682       return '{CRYPT}'. $self->_password;
2683     } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
2684       return '{MD5}'. $1;
2685     #} elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
2686     #  die "Blowfish encryption not supported in this context, svcnum ".
2687     #      $self->svcnum. "\n";
2688     } else {
2689       warn "encryption method not (yet?) supported in LDAP context";
2690       return '{CRYPT}*'; #unsupported, should not auth
2691     }
2692
2693   } elsif ( $self->_password_encoding eq 'plain' ) {
2694
2695     return '{PLAIN}'. $self->_password;
2696
2697     #return '{CLEARTEXT}'. $self->_password; #?
2698
2699   } else {
2700
2701     if ( length($self->_password) == 13 ) { #crypt
2702       return '{CRYPT}'. $self->_password;
2703     } elsif ( $self->_password =~ /^\$1\$(.*)$/ && length($1) == 31 ) { #passwdMD5
2704       return '{MD5}'. $1;
2705     } elsif ( $self->_password =~ /^\$2a?\$(.*)$/ ) { #Blowfish
2706       warn "Blowfish encryption not supported in this context, svcnum ".
2707           $self->svcnum. "\n";
2708       return '{CRYPT}*';
2709
2710     #are these two necessary anymore?
2711     } elsif ( $self->_password =~ /^(\w{48})$/ ) { #LDAP SSHA
2712       return '{SSHA}'. $1;
2713     } elsif ( $self->_password =~ /^(\w{64})$/ ) { #LDAP NS-MTA-MD5
2714       return '{NS-MTA-MD5}'. $1;
2715
2716     } else { #plaintext
2717       return '{PLAIN}'. $self->_password;
2718
2719       #return '{CLEARTEXT}'. $self->_password; #?
2720       
2721       #XXX this could be replaced with Authen::Passphrase stuff if it gets used
2722       #my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
2723       #if ( $encryption eq 'crypt' ) {
2724       #  return '{CRYPT}'. crypt(
2725       #    $self->_password,
2726       #    $saltset[int(rand(64))].$saltset[int(rand(64))]
2727       #  );
2728       #} elsif ( $encryption eq 'md5' ) {
2729       #  unix_md5_crypt( $self->_password );
2730       #} elsif ( $encryption eq 'blowfish' ) {
2731       #  croak "unknown encryption method $encryption";
2732       #} else {
2733       #  croak "unknown encryption method $encryption";
2734       #}
2735     }
2736
2737   }
2738
2739 }
2740
2741 =item domain_slash_username
2742
2743 Returns $domain/$username/
2744
2745 =cut
2746
2747 sub domain_slash_username {
2748   my $self = shift;
2749   $self->domain. '/'. $self->username. '/';
2750 }
2751
2752 =item virtual_maildir
2753
2754 Returns $domain/maildirs/$username/
2755
2756 =cut
2757
2758 sub virtual_maildir {
2759   my $self = shift;
2760   $self->domain. '/maildirs/'. $self->username. '/';
2761 }
2762
2763 =back
2764
2765 =head1 CLASS METHODS
2766
2767 =over 4
2768
2769 =item search HASHREF
2770
2771 Class method which returns a qsearch hash expression to search for parameters
2772 specified in HASHREF.  Valid parameters are
2773
2774 =over 4
2775
2776 =item domain
2777
2778 =item domsvc
2779
2780 =item unlinked
2781
2782 =item agentnum
2783
2784 =item pkgpart
2785
2786 Arrayref of pkgparts
2787
2788 =item pkgpart
2789
2790 =item where
2791
2792 Arrayref of additional WHERE clauses, will be ANDed together.
2793
2794 =item order_by
2795
2796 =item cust_fields
2797
2798 =back
2799
2800 =cut
2801
2802 sub search {
2803   my ($class, $params) = @_;
2804
2805   my @where = ();
2806
2807   # domain
2808   if ( $params->{'domain'} ) { 
2809     my $svc_domain = qsearchs('svc_domain', { 'domain'=>$params->{'domain'} } );
2810     #preserve previous behavior & bubble up an error if $svc_domain not found?
2811     push @where, 'domsvc = '. $svc_domain->svcnum if $svc_domain;
2812   }
2813
2814   # domsvc
2815   if ( $params->{'domsvc'} =~ /^(\d+)$/ ) { 
2816     push @where, "domsvc = $1";
2817   }
2818
2819   #unlinked
2820   push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
2821
2822   #agentnum
2823   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2824     push @where, "cust_main.agentnum = $1";
2825   }
2826
2827   #custnum
2828   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2829     push @where, "custnum = $1";
2830   }
2831
2832   #pkgpart
2833   if ( $params->{'pkgpart'} && scalar(@{ $params->{'pkgpart'} }) ) {
2834     #XXX untaint or sql quote
2835     push @where,
2836       'cust_pkg.pkgpart IN ('. join(',', @{ $params->{'pkgpart'} } ). ')';
2837   }
2838
2839   # popnum
2840   if ( $params->{'popnum'} =~ /^(\d+)$/ ) { 
2841     push @where, "popnum = $1";
2842   }
2843
2844   # svcpart
2845   if ( $params->{'svcpart'} =~ /^(\d+)$/ ) { 
2846     push @where, "svcpart = $1";
2847   }
2848
2849
2850   # here is the agent virtualization
2851   #if ($params->{CurrentUser}) {
2852   #  my $access_user =
2853   #    qsearchs('access_user', { username => $params->{CurrentUser} });
2854   #
2855   #  if ($access_user) {
2856   #    push @where, $access_user->agentnums_sql('table'=>'cust_main');
2857   #  }else{
2858   #    push @where, "1=0";
2859   #  }
2860   #} else {
2861     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
2862                    'table'      => 'cust_main',
2863                    'null_right' => 'View/link unlinked services',
2864                  );
2865   #}
2866
2867   push @where, @{ $params->{'where'} } if $params->{'where'};
2868
2869   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2870
2871   my $addl_from = ' LEFT JOIN cust_svc  USING ( svcnum  ) '.
2872                   ' LEFT JOIN part_svc  USING ( svcpart ) '.
2873                   ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
2874                   ' LEFT JOIN cust_main USING ( custnum ) ';
2875
2876   my $count_query = "SELECT COUNT(*) FROM svc_acct $addl_from $extra_sql";
2877   #if ( keys %svc_acct ) {
2878   #  $count_query .= ' WHERE '.
2879   #                    join(' AND ', map "$_ = ". dbh->quote($svc_acct{$_}),
2880   #                                      keys %svc_acct
2881   #                        );
2882   #}
2883
2884   my $sql_query = {
2885     'table'       => 'svc_acct',
2886     'hashref'     => {}, # \%svc_acct,
2887     'select'      => join(', ',
2888                        'svc_acct.*',
2889                        'part_svc.svc',
2890                        'cust_main.custnum',
2891                        FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
2892                      ),
2893     'addl_from'   => $addl_from,
2894     'extra_sql'   => $extra_sql,
2895     'order_by'    => $params->{'order_by'},
2896     'count_query' => $count_query,
2897   };
2898
2899 }
2900
2901 =back
2902
2903 =head1 SUBROUTINES
2904
2905 =over 4
2906
2907 =item send_email
2908
2909 This is the FS::svc_acct job-queue-able version.  It still uses
2910 FS::Misc::send_email under-the-hood.
2911
2912 =cut
2913
2914 sub send_email {
2915   my %opt = @_;
2916
2917   eval "use FS::Misc qw(send_email)";
2918   die $@ if $@;
2919
2920   $opt{mimetype} ||= 'text/plain';
2921   $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
2922
2923   my $error = send_email(
2924     'from'         => $opt{from},
2925     'to'           => $opt{to},
2926     'subject'      => $opt{subject},
2927     'content-type' => $opt{mimetype},
2928     'body'         => [ map "$_\n", split("\n", $opt{body}) ],
2929   );
2930   die $error if $error;
2931 }
2932
2933 =item check_and_rebuild_fuzzyfiles
2934
2935 =cut
2936
2937 sub check_and_rebuild_fuzzyfiles {
2938   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2939   -e "$dir/svc_acct.username"
2940     or &rebuild_fuzzyfiles;
2941 }
2942
2943 =item rebuild_fuzzyfiles
2944
2945 =cut
2946
2947 sub rebuild_fuzzyfiles {
2948
2949   use Fcntl qw(:flock);
2950
2951   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2952
2953   #username
2954
2955   open(USERNAMELOCK,">>$dir/svc_acct.username")
2956     or die "can't open $dir/svc_acct.username: $!";
2957   flock(USERNAMELOCK,LOCK_EX)
2958     or die "can't lock $dir/svc_acct.username: $!";
2959
2960   my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
2961
2962   open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
2963     or die "can't open $dir/svc_acct.username.tmp: $!";
2964   print USERNAMECACHE join("\n", @all_username), "\n";
2965   close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
2966
2967   rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
2968   close USERNAMELOCK;
2969
2970 }
2971
2972 =item all_username
2973
2974 =cut
2975
2976 sub all_username {
2977   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2978   open(USERNAMECACHE,"<$dir/svc_acct.username")
2979     or die "can't open $dir/svc_acct.username: $!";
2980   my @array = map { chomp; $_; } <USERNAMECACHE>;
2981   close USERNAMECACHE;
2982   \@array;
2983 }
2984
2985 =item append_fuzzyfiles USERNAME
2986
2987 =cut
2988
2989 sub append_fuzzyfiles {
2990   my $username = shift;
2991
2992   &check_and_rebuild_fuzzyfiles;
2993
2994   use Fcntl qw(:flock);
2995
2996   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
2997
2998   open(USERNAME,">>$dir/svc_acct.username")
2999     or die "can't open $dir/svc_acct.username: $!";
3000   flock(USERNAME,LOCK_EX)
3001     or die "can't lock $dir/svc_acct.username: $!";
3002
3003   print USERNAME "$username\n";
3004
3005   flock(USERNAME,LOCK_UN)
3006     or die "can't unlock $dir/svc_acct.username: $!";
3007   close USERNAME;
3008
3009   1;
3010 }
3011
3012
3013 =item reached_threshold
3014
3015 Performs some activities when svc_acct thresholds (such as number of seconds
3016 remaining) are reached.  
3017
3018 =cut
3019
3020 sub reached_threshold {
3021   my %opt = @_;
3022
3023   my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $opt{'svcnum'} } );
3024   die "Cannot find svc_acct with svcnum " . $opt{'svcnum'} unless $svc_acct;
3025
3026   if ( $opt{'op'} eq '+' ){
3027     $svc_acct->setfield( $opt{'column'}.'_threshold',
3028                          int($svc_acct->getfield($opt{'column'})
3029                              * ( $conf->exists('svc_acct-usage_threshold') 
3030                                  ? $conf->config('svc_acct-usage_threshold')/100
3031                                  : 0.80
3032                                )
3033                          )
3034                        );
3035     my $error = $svc_acct->replace;
3036     die $error if $error;
3037   }elsif ( $opt{'op'} eq '-' ){
3038     
3039     my $threshold = $svc_acct->getfield( $opt{'column'}.'_threshold' );
3040     return '' if ($threshold eq '' );
3041
3042     $svc_acct->setfield( $opt{'column'}.'_threshold', 0 );
3043     my $error = $svc_acct->replace;
3044     die $error if $error; # email next time, i guess
3045
3046     if ( $warning_template ) {
3047       eval "use FS::Misc qw(send_email)";
3048       die $@ if $@;
3049
3050       my $cust_pkg  = $svc_acct->cust_svc->cust_pkg;
3051       my $cust_main = $cust_pkg->cust_main;
3052
3053       my $to = join(', ', grep { $_ !~ /^(POST|FAX)$/ } 
3054                                $cust_main->invoicing_list,
3055                                ($opt{'to'} ? $opt{'to'} : ())
3056                    );
3057
3058       my $mimetype = $warning_mimetype;
3059       $mimetype .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
3060
3061       my $body       =  $warning_template->fill_in( HASH => {
3062                         'custnum'   => $cust_main->custnum,
3063                         'username'  => $svc_acct->username,
3064                         'password'  => $svc_acct->_password,
3065                         'first'     => $cust_main->first,
3066                         'last'      => $cust_main->getfield('last'),
3067                         'pkg'       => $cust_pkg->part_pkg->pkg,
3068                         'column'    => $opt{'column'},
3069                         'amount'    => $opt{'column'} =~/bytes/
3070                                        ? FS::UI::bytecount::display_bytecount($svc_acct->getfield($opt{'column'}))
3071                                        : $svc_acct->getfield($opt{'column'}),
3072                         'threshold' => $opt{'column'} =~/bytes/
3073                                        ? FS::UI::bytecount::display_bytecount($threshold)
3074                                        : $threshold,
3075                       } );
3076
3077
3078       my $error = send_email(
3079         'from'         => $warning_from,
3080         'to'           => $to,
3081         'subject'      => $warning_subject,
3082         'content-type' => $mimetype,
3083         'body'         => [ map "$_\n", split("\n", $body) ],
3084       );
3085       die $error if $error;
3086     }
3087   }else{
3088     die "unknown op: " . $opt{'op'};
3089   }
3090 }
3091
3092 =back
3093
3094 =head1 BUGS
3095
3096 The $recref stuff in sub check should be cleaned up.
3097
3098 The suspend, unsuspend and cancel methods update the database, but not the
3099 current object.  This is probably a bug as it's unexpected and
3100 counterintuitive.
3101
3102 insertion of RADIUS group stuff in insert could be done with child_objects now
3103 (would probably clean up export of them too)
3104
3105 _op_usage and set_usage bypass the history... maybe they shouldn't
3106
3107 =head1 SEE ALSO
3108
3109 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
3110 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
3111 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
3112 L<freeside-queued>), L<FS::svc_acct_pop>,
3113 schema.html from the base documentation.
3114
3115 =cut
3116
3117 1;