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