Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / contact.pm
1 package FS::contact;
2 use base qw( FS::Password_Mixin
3              FS::Record );
4
5 use strict;
6 use vars qw( $skip_fuzzyfiles );
7 use Carp;
8 use Scalar::Util qw( blessed );
9 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::Cursor;
11 use FS::contact_phone;
12 use FS::contact_email;
13 use FS::contact::Import;
14 use FS::queue;
15 use FS::phone_type; #for cgi_contact_fields
16 use FS::cust_contact;
17 use FS::prospect_contact;
18
19 $skip_fuzzyfiles = 0;
20
21 =head1 NAME
22
23 FS::contact - Object methods for contact records
24
25 =head1 SYNOPSIS
26
27   use FS::contact;
28
29   $record = new FS::contact \%hash;
30   $record = new FS::contact { 'column' => 'value' };
31
32   $error = $record->insert;
33
34   $error = $new_record->replace($old_record);
35
36   $error = $record->delete;
37
38   $error = $record->check;
39
40 =head1 DESCRIPTION
41
42 An FS::contact object represents an specific contact person for a prospect or
43 customer.  FS::contact inherits from FS::Record.  The following fields are
44 currently supported:
45
46 =over 4
47
48 =item contactnum
49
50 primary key
51
52 =item prospectnum
53
54 prospectnum
55
56 =item custnum
57
58 custnum
59
60 =item locationnum
61
62 locationnum
63
64 =item last
65
66 last
67
68 =item first
69
70 first
71
72 =item title
73
74 title
75
76 =item comment
77
78 comment
79
80 =item selfservice_access
81
82 empty or Y
83
84 =item _password
85
86 =item _password_encoding
87
88 empty or bcrypt
89
90 =item disabled
91
92 disabled
93
94 =back
95
96 =head1 METHODS
97
98 =over 4
99
100 =item new HASHREF
101
102 Creates a new contact.  To add the contact to the database, see L<"insert">.
103
104 Note that this stores the hash reference, not a distinct copy of the hash it
105 points to.  You can ask the object for a copy with the I<hash> method.
106
107 =cut
108
109 sub table { 'contact'; }
110
111 =item insert
112
113 Adds this record to the database.  If there is an error, returns the error,
114 otherwise returns false.
115
116 If the object has an C<emailaddress> field, L<FS::contact_email> records
117 will be created for each (comma-separated) email address in that field. If
118 any of these coincide with an existing email address, this contact will be
119 merged with the contact with that address.
120
121 Then, if the object has any fields named C<phonetypenumN> an
122 L<FS::contact_phone> record will be created for each of them. Those fields
123 should contain phone numbers of the appropriate types (where N is the key of
124 an L<FS::phone_type> record identifying the type of number: daytime, night,
125 etc.).
126
127 After inserting the record, if the object has a 'custnum' or 'prospectnum'
128 field, an L<FS::cust_contact> or L<FS::prospect_contact> record will be
129 created to link the contact to the customer. The following fields will also
130 be included in that record, if they are set on the object:
131 - classnum
132 - comment
133 - selfservice_access
134 - invoice_dest
135
136 =cut
137
138 sub insert {
139   my $self = shift;
140
141   local $SIG{INT} = 'IGNORE';
142   local $SIG{QUIT} = 'IGNORE';
143   local $SIG{TERM} = 'IGNORE';
144   local $SIG{TSTP} = 'IGNORE';
145   local $SIG{PIPE} = 'IGNORE';
146
147   my $oldAutoCommit = $FS::UID::AutoCommit;
148   local $FS::UID::AutoCommit = 0;
149   my $dbh = dbh;
150
151   #save off and blank values that move to cust_contact / prospect_contact now
152   my $prospectnum = $self->prospectnum;
153   $self->prospectnum('');
154   my $custnum = $self->custnum;
155   $self->custnum('');
156
157   my %link_hash = ();
158   for (qw( classnum comment selfservice_access invoice_dest )) {
159     $link_hash{$_} = $self->get($_);
160     $self->$_('');
161   }
162
163   #look for an existing contact with this email address
164   my $existing_contact = '';
165   if ( $self->get('emailaddress') =~ /\S/ ) {
166   
167     my %existing_contact = ();
168
169     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
170  
171       my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
172         or next;
173
174       my $contact = $contact_email->contact;
175       $existing_contact{ $contact->contactnum } = $contact;
176
177     }
178
179     if ( scalar( keys %existing_contact ) > 1 ) {
180       $dbh->rollback if $oldAutoCommit;
181       return 'Multiple email addresses specified '.
182              ' that already belong to separate contacts';
183     } elsif ( scalar( keys %existing_contact ) ) {
184       ($existing_contact) = values %existing_contact;
185     }
186
187   }
188
189   my $error;
190   if ( $existing_contact ) {
191
192     $self->$_($existing_contact->$_())
193       for qw( contactnum _password _password_encoding );
194     $error = $self->SUPER::replace($existing_contact);
195
196   } else {
197
198     $error = $self->SUPER::insert;
199
200   }
201
202   $error ||= $self->insert_password_history;
203
204   if ( $error ) {
205     $dbh->rollback if $oldAutoCommit;
206     return $error;
207   }
208
209   my $cust_contact = '';
210   # if $self->custnum was set, then the customer-specific properties
211   # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
212   # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
213   # fields.
214   if ( $custnum ) {
215     my %hash = ( 'contactnum' => $self->contactnum,
216                  'custnum'    => $custnum,
217                );
218     $cust_contact =  qsearchs('cust_contact', \%hash )
219                   || new FS::cust_contact { %hash, %link_hash };
220     my $error = $cust_contact->custcontactnum ? $cust_contact->replace
221                                               : $cust_contact->insert;
222     if ( $error ) {
223       $dbh->rollback if $oldAutoCommit;
224       return $error;
225     }
226   }
227
228   if ( $prospectnum ) {
229     my %hash = ( 'contactnum'  => $self->contactnum,
230                  'prospectnum' => $prospectnum,
231                );
232     my $prospect_contact =  qsearchs('prospect_contact', \%hash )
233                          || new FS::prospect_contact { %hash, %link_hash };
234     my $error =
235       $prospect_contact->prospectcontactnum ? $prospect_contact->replace
236                                             : $prospect_contact->insert;
237     if ( $error ) {
238       $dbh->rollback if $oldAutoCommit;
239       return $error;
240     }
241   }
242
243   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
244                         keys %{ $self->hashref } ) {
245     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
246     my $phonetypenum = $1;
247
248     my %hash = ( 'contactnum'   => $self->contactnum,
249                  'phonetypenum' => $phonetypenum,
250                );
251     my $contact_phone =
252       qsearchs('contact_phone', \%hash)
253         || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
254     my $error = $contact_phone->contactphonenum ? $contact_phone->replace
255                                                 : $contact_phone->insert;
256     if ( $error ) {
257       $dbh->rollback if $oldAutoCommit;
258       return $error;
259     }
260   }
261
262   if ( $self->get('emailaddress') =~ /\S/ ) {
263
264     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
265       my %hash = (
266         'contactnum'   => $self->contactnum,
267         'emailaddress' => $email,
268       );
269       unless ( qsearchs('contact_email', \%hash) ) {
270         my $contact_email = new FS::contact_email \%hash;
271         my $error = $contact_email->insert;
272         if ( $error ) {
273           $dbh->rollback if $oldAutoCommit;
274           return $error;
275         }
276       }
277     }
278
279   }
280
281   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
282     #warn "  queueing fuzzyfiles update\n"
283     #  if $DEBUG > 1;
284     my $error = $self->queue_fuzzyfiles_update;
285     if ( $error ) {
286       $dbh->rollback if $oldAutoCommit;
287       return "updating fuzzy search cache: $error";
288     }
289   }
290
291   if (      $link_hash{'selfservice_access'} eq 'R'
292        or ( $link_hash{'selfservice_access'}
293             && $cust_contact
294             && ! length($self->_password)
295           )
296      )
297   {
298     my $error = $self->send_reset_email( queue=>1 );
299     if ( $error ) {
300       $dbh->rollback if $oldAutoCommit;
301       return $error;
302     }
303   }
304
305   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
306
307   '';
308
309 }
310
311 =item delete
312
313 Delete this record from the database.
314
315 =cut
316
317 sub delete {
318   my $self = shift;
319
320   local $SIG{HUP} = 'IGNORE';
321   local $SIG{INT} = 'IGNORE';
322   local $SIG{QUIT} = 'IGNORE';
323   local $SIG{TERM} = 'IGNORE';
324   local $SIG{TSTP} = 'IGNORE';
325   local $SIG{PIPE} = 'IGNORE';
326
327   my $oldAutoCommit = $FS::UID::AutoCommit;
328   local $FS::UID::AutoCommit = 0;
329   my $dbh = dbh;
330
331   #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
332
333   if ( $self->prospectnum ) {
334     my $prospect_contact = qsearchs('prospect_contact', {
335                              'contactnum'  => $self->contactnum,
336                              'prospectnum' => $self->prospectnum,
337                            });
338     my $error = $prospect_contact->delete;
339     if ( $error ) {
340       $dbh->rollback if $oldAutoCommit;
341       return $error;
342     }
343   }
344
345   # if $self->custnum was set, then we're removing the contact from this
346   # customer.
347   if ( $self->custnum ) {
348     my $cust_contact = qsearchs('cust_contact', {
349                          'contactnum'  => $self->contactnum,
350                          'custnum' => $self->custnum,
351                        });
352     my $error = $cust_contact->delete;
353     if ( $error ) {
354       $dbh->rollback if $oldAutoCommit;
355       return $error;
356     }
357   }
358
359   # then, proceed with deletion only if the contact isn't attached to any other
360   # prospects or customers
361
362   #inefficient, but how many prospects/customers can a single contact be
363   # attached too?  (and is removing them from one a common operation?)
364   if ( $self->prospect_contact || $self->cust_contact ) {
365     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
366     return '';
367   }
368
369   #proceed with deletion
370
371   foreach my $cust_pkg ( $self->cust_pkg ) {
372     $cust_pkg->contactnum('');
373     my $error = $cust_pkg->replace;
374     if ( $error ) {
375       $dbh->rollback if $oldAutoCommit;
376       return $error;
377     }
378   }
379
380   foreach my $object ( $self->contact_phone, $self->contact_email ) {
381     my $error = $object->delete;
382     if ( $error ) {
383       $dbh->rollback if $oldAutoCommit;
384       return $error;
385     }
386   }
387
388   my $error = $self->delete_password_history
389            || $self->SUPER::delete;
390   if ( $error ) {
391     $dbh->rollback if $oldAutoCommit;
392     return $error;
393   }
394
395   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
396   '';
397
398 }
399
400 =item replace OLD_RECORD
401
402 Replaces the OLD_RECORD with this one in the database.  If there is an error,
403 returns the error, otherwise returns false.
404
405 =cut
406
407 sub replace {
408   my $self = shift;
409
410   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
411               ? shift
412               : $self->replace_old;
413
414   $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
415
416   local $SIG{INT} = 'IGNORE';
417   local $SIG{QUIT} = 'IGNORE';
418   local $SIG{TERM} = 'IGNORE';
419   local $SIG{TSTP} = 'IGNORE';
420   local $SIG{PIPE} = 'IGNORE';
421
422   my $oldAutoCommit = $FS::UID::AutoCommit;
423   local $FS::UID::AutoCommit = 0;
424   my $dbh = dbh;
425
426   #save off and blank values that move to cust_contact / prospect_contact now
427   my $prospectnum = $self->prospectnum;
428   $self->prospectnum('');
429   my $custnum = $self->custnum;
430   $self->custnum('');
431
432   my %link_hash = ();
433   for (qw( classnum comment selfservice_access invoice_dest )) {
434     $link_hash{$_} = $self->get($_);
435     $self->$_('');
436   }
437
438   my $error = $self->SUPER::replace($old);
439   if ( $old->_password ne $self->_password ) {
440     $error ||= $self->insert_password_history;
441   }
442   if ( $error ) {
443     $dbh->rollback if $oldAutoCommit;
444     return $error;
445   }
446
447   my $cust_contact = '';
448   # if $self->custnum was set, then the customer-specific properties
449   # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
450   # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
451   # fields.
452   if ( $custnum ) {
453     my %hash = ( 'contactnum' => $self->contactnum,
454                  'custnum'    => $custnum,
455                );
456     my $error;
457     if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
458       $cust_contact->$_($link_hash{$_}) for keys %link_hash;
459       $error = $cust_contact->replace;
460     } else {
461       $cust_contact = new FS::cust_contact { %hash, %link_hash };
462       $error = $cust_contact->insert;
463     }
464     if ( $error ) {
465       $dbh->rollback if $oldAutoCommit;
466       return $error;
467     }
468   }
469
470   if ( $prospectnum ) {
471     my %hash = ( 'contactnum'  => $self->contactnum,
472                  'prospectnum' => $prospectnum,
473                );
474     my $error;
475     if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
476       $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
477       $error = $prospect_contact->replace;
478     } else {
479       my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
480       $error = $prospect_contact->insert;
481     }
482     if ( $error ) {
483       $dbh->rollback if $oldAutoCommit;
484       return $error;
485     }
486   }
487
488   foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
489                         keys %{ $self->hashref } ) {
490     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
491     my $phonetypenum = $1;
492
493     my %cp = ( 'contactnum'   => $self->contactnum,
494                'phonetypenum' => $phonetypenum,
495              );
496     my $contact_phone = qsearchs('contact_phone', \%cp);
497
498     my $pv = $self->get($pf);
499         $pv =~ s/\s//g;
500
501     #if new value is empty, delete old entry
502     if (!$pv) {
503       if ($contact_phone) {
504         $error = $contact_phone->delete;
505         if ( $error ) {
506           $dbh->rollback if $oldAutoCommit;
507           return $error;
508         }
509       }
510       next;
511     }
512
513     $contact_phone ||= new FS::contact_phone \%cp;
514
515     my %cpd = _parse_phonestring( $pv );
516     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
517
518     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
519
520     $error = $contact_phone->$method;
521     if ( $error ) {
522       $dbh->rollback if $oldAutoCommit;
523       return $error;
524     }
525   }
526
527   if ( defined($self->hashref->{'emailaddress'}) ) {
528
529     #ineffecient but whatever, how many email addresses can there be?
530
531     foreach my $contact_email ( $self->contact_email ) {
532       my $error = $contact_email->delete;
533       if ( $error ) {
534         $dbh->rollback if $oldAutoCommit;
535         return $error;
536       }
537     }
538
539     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
540  
541       my $contact_email = new FS::contact_email {
542         'contactnum'   => $self->contactnum,
543         'emailaddress' => $email,
544       };
545       $error = $contact_email->insert;
546       if ( $error ) {
547         $dbh->rollback if $oldAutoCommit;
548         return $error;
549       }
550
551     }
552
553   }
554
555   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
556     #warn "  queueing fuzzyfiles update\n"
557     #  if $DEBUG > 1;
558     $error = $self->queue_fuzzyfiles_update;
559     if ( $error ) {
560       $dbh->rollback if $oldAutoCommit;
561       return "updating fuzzy search cache: $error";
562     }
563   }
564
565   if ( $cust_contact and (
566                               (      $cust_contact->selfservice_access eq ''
567                                   && $link_hash{selfservice_access}
568                                   && ! length($self->_password)
569                               )
570                            || $cust_contact->_resend()
571                          )
572     )
573   {
574     my $error = $self->send_reset_email( queue=>1 );
575     if ( $error ) {
576       $dbh->rollback if $oldAutoCommit;
577       return $error;
578     }
579   }
580
581   if ( $self->get('password') ) {
582     my $error = $self->is_password_allowed($self->get('password'))
583           ||  $self->change_password($self->get('password'));
584     if ( $error ) {
585       $dbh->rollback if $oldAutoCommit;
586       return $error;
587     }
588   }
589
590   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
591
592   '';
593
594 }
595
596 =item _parse_phonestring PHONENUMBER_STRING
597
598 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
599 with keys 'countrycode', 'phonenum' and 'extension'
600
601 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
602
603 =cut
604
605 sub _parse_phonestring {
606   my $value = shift;
607
608   my($countrycode, $extension) = ('1', '');
609
610   #countrycode
611   if ( $value =~ s/^\s*\+\s*(\d+)// ) {
612     $countrycode = $1;
613   } else {
614     $value =~ s/^\s*1//;
615   }
616   #extension
617   if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
618      $extension = $2;
619   }
620
621   ( 'countrycode' => $countrycode,
622     'phonenum'    => $value,
623     'extension'   => $extension,
624   );
625 }
626
627 =item queue_fuzzyfiles_update
628
629 Used by insert & replace to update the fuzzy search cache
630
631 =cut
632
633 use FS::cust_main::Search;
634 sub queue_fuzzyfiles_update {
635   my $self = shift;
636
637   local $SIG{HUP} = 'IGNORE';
638   local $SIG{INT} = 'IGNORE';
639   local $SIG{QUIT} = 'IGNORE';
640   local $SIG{TERM} = 'IGNORE';
641   local $SIG{TSTP} = 'IGNORE';
642   local $SIG{PIPE} = 'IGNORE';
643
644   my $oldAutoCommit = $FS::UID::AutoCommit;
645   local $FS::UID::AutoCommit = 0;
646   my $dbh = dbh;
647
648   foreach my $field ( 'first', 'last' ) {
649     my $queue = new FS::queue { 
650       'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
651     };
652     my @args = "contact.$field", $self->get($field);
653     my $error = $queue->insert( @args );
654     if ( $error ) {
655       $dbh->rollback if $oldAutoCommit;
656       return "queueing job (transaction rolled back): $error";
657     }
658   }
659
660   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
661   '';
662
663 }
664
665 =item check
666
667 Checks all fields to make sure this is a valid contact.  If there is
668 an error, returns the error, otherwise returns false.  Called by the insert
669 and replace methods.
670
671 =cut
672
673 sub check {
674   my $self = shift;
675
676   if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'P' ) {
677     $self->selfservice_access('Y');
678     $self->_resend('Y');
679   }
680
681   my $error = 
682     $self->ut_numbern('contactnum')
683     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
684     || $self->ut_foreign_keyn('custnum',     'cust_main',     'custnum')
685     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
686     || $self->ut_foreign_keyn('classnum',    'contact_class', 'classnum')
687     || $self->ut_namen('last')
688     || $self->ut_namen('first')
689     || $self->ut_textn('title')
690     || $self->ut_textn('comment')
691     || $self->ut_enum('selfservice_access', [ '', 'Y' ])
692     || $self->ut_textn('_password')
693     || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
694     || $self->ut_enum('disabled', [ '', 'Y' ])
695   ;
696   return $error if $error;
697
698   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
699
700   return "One of first name, last name, or title must have a value"
701     if ! grep $self->$_(), qw( first last title);
702
703   $self->SUPER::check;
704 }
705
706 =item line
707
708 Returns a formatted string representing this contact, including name, title and
709 comment.
710
711 =cut
712
713 sub line {
714   my $self = shift;
715   my $data = $self->first. ' '. $self->last;
716   $data .= ', '. $self->title
717     if $self->title;
718   $data .= ' ('. $self->comment. ')'
719     if $self->comment;
720   $data;
721 }
722
723 =item firstlast
724
725 Returns a formatted string representing this contact, with just the name.
726
727 =cut
728
729 sub firstlast {
730   my $self = shift;
731   $self->first . ' ' . $self->last;
732 }
733
734 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
735 #
736 #Returns the name of this contact's class for the specified prospect or
737 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
738 #L<FS::contact_class>).
739 #
740 #=cut
741 #
742 #sub contact_classname {
743 #  my( $self, $prospect_or_cust ) = @_;
744 #
745 #  my $link = '';
746 #  if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
747 #    $link = qsearchs('prospect_contact', {
748 #              'contactnum'  => $self->contactnum,
749 #              'prospectnum' => $prospect_or_cust->prospectnum,
750 #            });
751 #  } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
752 #    $link = qsearchs('cust_contact', {
753 #              'contactnum'  => $self->contactnum,
754 #              'custnum'     => $prospect_or_cust->custnum,
755 #            });
756 #  } else {
757 #    croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
758 #  }
759 #
760 #  my $contact_class = $link->contact_class or return '';
761 #  $contact_class->classname;
762 #}
763
764 =item by_selfservice_email EMAILADDRESS
765
766 Alternate search constructor (class method).  Given an email address, returns
767 the contact for that address. If that contact doesn't have selfservice access,
768 or there isn't one, returns the empty string.
769
770 =cut
771
772 sub by_selfservice_email {
773   my($class, $email) = @_;
774
775   my $contact_email = qsearchs({
776     'table'     => 'contact_email',
777     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
778     'hashref'   => { 'emailaddress' => $email, },
779     'extra_sql' => "
780       AND ( contact.disabled IS NULL )
781       AND EXISTS ( SELECT 1 FROM cust_contact
782                      WHERE contact.contactnum = cust_contact.contactnum
783                        AND cust_contact.selfservice_access = 'Y'
784                  )
785     ",
786   }) or return '';
787
788   $contact_email->contact;
789
790 }
791
792 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
793 # and should maybe be libraried in some way for other password needs
794
795 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
796
797 sub authenticate_password {
798   my($self, $check_password) = @_;
799
800   if ( $self->_password_encoding eq 'bcrypt' ) {
801
802     my( $cost, $salt, $hash ) = split(',', $self->_password);
803
804     my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
805                                                cost    => $cost,
806                                                salt    => de_base64($salt),
807                                              },
808                                              $check_password
809                                            )
810                               );
811
812     $hash eq $check_hash;
813
814   } else { 
815
816     return 0 if $self->_password eq '';
817
818     $self->_password eq $check_password;
819
820   }
821
822 }
823
824 =item change_password NEW_PASSWORD
825
826 Changes the contact's selfservice access password to NEW_PASSWORD. This does
827 not check password policy rules (see C<is_password_allowed>) and will return
828 an error only if editing the record fails for some reason.
829
830 If NEW_PASSWORD is the same as the existing password, this does nothing.
831
832 =cut
833
834 sub change_password {
835   my($self, $new_password) = @_;
836
837   # do nothing if the password is unchanged
838   return if $self->authenticate_password($new_password);
839
840   $self->change_password_fields( $new_password );
841
842   $self->replace;
843
844 }
845
846 sub change_password_fields {
847   my($self, $new_password) = @_;
848
849   $self->_password_encoding('bcrypt');
850
851   my $cost = 8;
852
853   my $salt = pack( 'C*', map int(rand(256)), 1..16 );
854
855   my $hash = bcrypt_hash( { key_nul => 1,
856                             cost    => $cost,
857                             salt    => $salt,
858                           },
859                           $new_password,
860                         );
861
862   $self->_password(
863     join(',', $cost, en_base64($salt), en_base64($hash) )
864   );
865
866 }
867
868 # end of false laziness w/FS/FS/Auth/internal.pm
869
870
871 #false laziness w/ClientAPI/MyAccount/reset_passwd
872 use Digest::SHA qw(sha512_hex);
873 use FS::Conf;
874 use FS::ClientAPI_SessionCache;
875 sub send_reset_email {
876   my( $self, %opt ) = @_;
877
878   my @contact_email = $self->contact_email or return '';
879
880   my $reset_session = {
881     'contactnum' => $self->contactnum,
882     'svcnum'     => $opt{'svcnum'},
883   };
884
885   
886   my $conf = new FS::Conf;
887   my $timeout =
888     ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
889
890   my $reset_session_id;
891   do {
892     $reset_session_id = sha512_hex(time(). {}. rand(). $$)
893   } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
894     #just in case
895
896   $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
897
898   #email it
899
900   my $cust_main = '';
901   my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
902   $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
903
904   my $agentnum = $cust_main ? $cust_main->agentnum : '';
905   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
906   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
907   return "selfservice-password_reset_msgnum unset" unless $msgnum;
908   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
909   return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template;
910   my %msg_template = (
911     'to'            => join(',', map $_->emailaddress, @contact_email ),
912     'cust_main'     => $cust_main,
913     'object'        => $self,
914     'substitutions' => { 'session_id' => $reset_session_id }
915   );
916
917   if ( $opt{'queue'} ) { #or should queueing just be the default?
918
919     my $cust_msg = $msg_template->prepare( %msg_template );
920     my $error = $cust_msg->insert;
921     return $error if $error;
922     my $queue = new FS::queue {
923       'job'     => 'FS::cust_msg::process_send',
924       'custnum' => $cust_main ? $cust_main->custnum : '',
925     };
926     $queue->insert( $cust_msg->custmsgnum );
927
928   } else {
929
930     $msg_template->send( %msg_template );
931
932   }
933
934 }
935
936 use vars qw( $myaccount_cache );
937 sub myaccount_cache {
938   #my $class = shift;
939   $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
940                          'namespace' => 'FS::ClientAPI::MyAccount',
941                        } );
942 }
943
944 =item cgi_contact_fields
945
946 Returns a list reference containing the set of contact fields used in the web
947 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
948 and locationnum, as well as password fields, but including fields for
949 contact_email and contact_phone records.)
950
951 =cut
952
953 sub cgi_contact_fields {
954   #my $class = shift;
955
956   my @contact_fields = qw(
957     classnum first last title comment emailaddress selfservice_access
958     invoice_dest password
959   );
960
961   push @contact_fields, 'phonetypenum'. $_->phonetypenum
962     foreach qsearch({table=>'phone_type', order_by=>'weight'});
963
964   \@contact_fields;
965
966 }
967
968 use FS::upgrade_journal;
969 sub _upgrade_data { #class method
970   my ($class, %opts) = @_;
971
972   # before anything else, migrate contact.custnum to cust_contact records
973   unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) {
974
975     local($skip_fuzzyfiles) = 1;
976
977     foreach my $contact (qsearch('contact', {})) {
978       my $error = $contact->replace;
979       die $error if $error;
980     }
981
982     FS::upgrade_journal->set_done('contact_invoice_dest');
983   }
984
985
986   # always migrate cust_main_invoice records over
987   local $FS::cust_main::import = 1; # override require_phone and such
988   my $search = FS::Cursor->new('cust_main_invoice', {});
989   my %custnum_dest;
990   while (my $cust_main_invoice = $search->fetch) {
991     my $custnum = $cust_main_invoice->custnum;
992     my $dest = $cust_main_invoice->dest;
993     my $cust_main = $cust_main_invoice->cust_main;
994
995     if ( $dest =~ /^\d+$/ ) {
996       my $svc_acct = FS::svc_acct->by_key($dest);
997       die "custnum $custnum, invoice destination svcnum $svc_acct does not exist\n"
998         if !$svc_acct;
999       $dest = $svc_acct->email;
1000     }
1001     push @{ $custnum_dest{$custnum} ||= [] }, $dest;
1002
1003     my $error = $cust_main_invoice->delete;
1004     if ( $error ) {
1005       die "custnum $custnum, cleaning up cust_main_invoice: $error\n";
1006     }
1007   }
1008
1009   foreach my $custnum (keys %custnum_dest) {
1010     my $dests = $custnum_dest{$custnum};
1011     my $cust_main = FS::cust_main->by_key($custnum);
1012     my $error = $cust_main->replace( invoicing_list => $dests );
1013     if ( $error ) {
1014       die "custnum $custnum, creating contact: $error\n";
1015     }
1016   }
1017
1018 }
1019
1020 =back
1021
1022 =head1 BUGS
1023
1024 =head1 SEE ALSO
1025
1026 L<FS::Record>, schema.html from the base documentation.
1027
1028 =cut
1029
1030 1;
1031