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