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