2 use base qw( FS::Password_Mixin
6 use vars qw( $skip_fuzzyfiles );
8 use Scalar::Util qw( blessed );
9 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::contact_phone;
12 use FS::contact_email;
13 use FS::contact::Import;
15 use FS::phone_type; #for cgi_contact_fields
17 use FS::prospect_contact;
23 FS::contact - Object methods for contact records
29 $record = new FS::contact \%hash;
30 $record = new FS::contact { 'column' => 'value' };
32 $error = $record->insert;
34 $error = $new_record->replace($old_record);
36 $error = $record->delete;
38 $error = $record->check;
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
80 =item selfservice_access
86 =item _password_encoding
102 Creates a new contact. To add the contact to the database, see L<"insert">.
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.
109 sub table { 'contact'; }
113 Adds this record to the database. If there is an error, returns the error,
114 otherwise returns false.
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.
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,
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:
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';
147 my $oldAutoCommit = $FS::UID::AutoCommit;
148 local $FS::UID::AutoCommit = 0;
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;
158 for (qw( classnum comment selfservice_access invoice_dest message_dest)) {
159 $link_hash{$_} = $self->get($_);
163 #look for an existing contact with this email address
164 my $existing_contact = '';
165 if ( $self->get('emailaddress') =~ /\S/ ) {
167 my %existing_contact = ();
169 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
171 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
174 my $contact = $contact_email->contact;
175 $existing_contact{ $contact->contactnum } = $contact;
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;
190 if ( $existing_contact ) {
192 $self->$_($existing_contact->$_())
193 for qw( contactnum _password _password_encoding );
194 $error = $self->SUPER::replace($existing_contact);
198 $error = $self->SUPER::insert;
203 $dbh->rollback if $oldAutoCommit;
207 my $cust_contact = '';
208 # if $self->custnum was set, then the customer-specific properties
209 # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
210 # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
213 my %hash = ( 'contactnum' => $self->contactnum,
214 'custnum' => $custnum,
216 $cust_contact = qsearchs('cust_contact', \%hash )
217 || new FS::cust_contact { %hash, %link_hash };
218 my $error = $cust_contact->custcontactnum ? $cust_contact->replace
219 : $cust_contact->insert;
221 $dbh->rollback if $oldAutoCommit;
226 if ( $prospectnum ) {
227 my %hash = ( 'contactnum' => $self->contactnum,
228 'prospectnum' => $prospectnum,
230 my $prospect_contact = qsearchs('prospect_contact', \%hash )
231 || new FS::prospect_contact { %hash, %link_hash };
233 $prospect_contact->prospectcontactnum ? $prospect_contact->replace
234 : $prospect_contact->insert;
236 $dbh->rollback if $oldAutoCommit;
241 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
242 keys %{ $self->hashref } ) {
243 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
244 my $phonetypenum = $1;
246 my %hash = ( 'contactnum' => $self->contactnum,
247 'phonetypenum' => $phonetypenum,
250 qsearchs('contact_phone', \%hash)
251 || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
252 my $error = $contact_phone->contactphonenum ? $contact_phone->replace
253 : $contact_phone->insert;
255 $dbh->rollback if $oldAutoCommit;
260 if ( $self->get('emailaddress') =~ /\S/ ) {
262 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
264 'contactnum' => $self->contactnum,
265 'emailaddress' => $email,
267 unless ( qsearchs('contact_email', \%hash) ) {
268 my $contact_email = new FS::contact_email \%hash;
269 my $error = $contact_email->insert;
271 $dbh->rollback if $oldAutoCommit;
279 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
280 #warn " queueing fuzzyfiles update\n"
282 my $error = $self->queue_fuzzyfiles_update;
284 $dbh->rollback if $oldAutoCommit;
285 return "updating fuzzy search cache: $error";
289 if ( $link_hash{'selfservice_access'} eq 'R'
290 or ( $link_hash{'selfservice_access'}
292 && ! length($self->_password)
296 my $error = $self->send_reset_email( queue=>1 );
298 $dbh->rollback if $oldAutoCommit;
303 if ( $self->get('password') ) {
304 my $error = $self->is_password_allowed($self->get('password'))
305 || $self->change_password($self->get('password'));
307 $dbh->rollback if $oldAutoCommit;
312 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
320 Delete this record from the database.
327 local $SIG{HUP} = 'IGNORE';
328 local $SIG{INT} = 'IGNORE';
329 local $SIG{QUIT} = 'IGNORE';
330 local $SIG{TERM} = 'IGNORE';
331 local $SIG{TSTP} = 'IGNORE';
332 local $SIG{PIPE} = 'IGNORE';
334 my $oldAutoCommit = $FS::UID::AutoCommit;
335 local $FS::UID::AutoCommit = 0;
338 #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
340 if ( $self->prospectnum ) {
341 my $prospect_contact = qsearchs('prospect_contact', {
342 'contactnum' => $self->contactnum,
343 'prospectnum' => $self->prospectnum,
345 my $error = $prospect_contact->delete;
347 $dbh->rollback if $oldAutoCommit;
352 # if $self->custnum was set, then we're removing the contact from this
354 if ( $self->custnum ) {
355 my $cust_contact = qsearchs('cust_contact', {
356 'contactnum' => $self->contactnum,
357 'custnum' => $self->custnum,
359 my $error = $cust_contact->delete;
361 $dbh->rollback if $oldAutoCommit;
366 # then, proceed with deletion only if the contact isn't attached to any other
367 # prospects or customers
369 #inefficient, but how many prospects/customers can a single contact be
370 # attached too? (and is removing them from one a common operation?)
371 if ( $self->prospect_contact || $self->cust_contact ) {
372 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
376 #proceed with deletion
378 foreach my $cust_pkg ( $self->cust_pkg ) {
379 $cust_pkg->contactnum('');
380 my $error = $cust_pkg->replace;
382 $dbh->rollback if $oldAutoCommit;
387 foreach my $object ( $self->contact_phone, $self->contact_email ) {
388 my $error = $object->delete;
390 $dbh->rollback if $oldAutoCommit;
395 my $error = $self->delete_password_history
396 || $self->SUPER::delete;
398 $dbh->rollback if $oldAutoCommit;
402 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
407 =item replace OLD_RECORD
409 Replaces the OLD_RECORD with this one in the database. If there is an error,
410 returns the error, otherwise returns false.
417 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
419 : $self->replace_old;
421 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
423 local $SIG{INT} = 'IGNORE';
424 local $SIG{QUIT} = 'IGNORE';
425 local $SIG{TERM} = 'IGNORE';
426 local $SIG{TSTP} = 'IGNORE';
427 local $SIG{PIPE} = 'IGNORE';
429 my $oldAutoCommit = $FS::UID::AutoCommit;
430 local $FS::UID::AutoCommit = 0;
433 #save off and blank values that move to cust_contact / prospect_contact now
434 my $prospectnum = $self->prospectnum;
435 $self->prospectnum('');
436 my $custnum = $self->custnum;
437 $self->custnum(''); $old->custnum(''); # remove because now stored cust_contact
440 for (qw( classnum comment selfservice_access invoice_dest message_dest )) {
441 $link_hash{$_} = $self->get($_);
442 $old->$_(''); ##remove values from old record, causes problem with history
446 ## check for an existing contact with this email address other than current customer
447 ## if found, just add that contact to cust_contact with link_hash credentials
448 ## as email can not be tied to two contacts.
449 my @contact_emails = ();
450 my @contact_nums = ($self->contactnum,);
451 if ( $self->get('emailaddress') =~ /\S/ ) {
453 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
455 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } );
456 unless ($contact_email) { push @contact_emails, $email; next; }
458 my $contact = $contact_email->contact;
459 if ($contact->contactnum eq $self->contactnum) {
460 push @contact_emails, $email;
463 push @contact_nums, $contact->contactnum;
468 my $emails = join(' , ', @contact_emails);
469 $self->emailaddress($emails);
473 my $error = $self->SUPER::replace($old);
474 if ( $old->_password ne $self->_password ) {
475 $error ||= $self->insert_password_history;
478 $dbh->rollback if $oldAutoCommit;
482 my $cust_contact = '';
483 # if $self->custnum was set, then the customer-specific properties
484 # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
485 # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
489 foreach my $contactnum (@contact_nums) {
491 my %hash = ( 'contactnum' => $contactnum, #$self->contactnum,
492 'custnum' => $custnum,
495 if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
496 $cust_contact->$_($link_hash{$_}) for keys %link_hash;
497 $error = $cust_contact->replace;
499 $cust_contact = new FS::cust_contact { %hash, %link_hash };
500 $error = $cust_contact->insert;
503 $dbh->rollback if $oldAutoCommit;
509 if ( $prospectnum ) {
510 my %hash = ( 'contactnum' => $self->contactnum,
511 'prospectnum' => $prospectnum,
514 if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
515 $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
516 $error = $prospect_contact->replace;
518 my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
519 $error = $prospect_contact->insert;
522 $dbh->rollback if $oldAutoCommit;
527 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
528 keys %{ $self->hashref } ) {
529 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
530 my $phonetypenum = $1;
532 my %cp = ( 'contactnum' => $self->contactnum,
533 'phonetypenum' => $phonetypenum,
535 my $contact_phone = qsearchs('contact_phone', \%cp);
537 my $pv = $self->get($pf);
540 #if new value is empty, delete old entry
542 if ($contact_phone) {
543 $error = $contact_phone->delete;
545 $dbh->rollback if $oldAutoCommit;
552 $contact_phone ||= new FS::contact_phone \%cp;
554 my %cpd = _parse_phonestring( $pv );
555 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
557 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
559 $error = $contact_phone->$method;
561 $dbh->rollback if $oldAutoCommit;
566 if ( defined($self->hashref->{'emailaddress'}) ) {
568 my %contact_emails = ();
569 foreach my $contact_email ( $self->contact_email ) {
570 $contact_emails{$contact_email->emailaddress} = '1';
573 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
575 unless ($contact_emails{$email}) {
576 my $contact_email = new FS::contact_email {
577 'contactnum' => $self->contactnum,
578 'emailaddress' => $email,
580 $error = $contact_email->insert;
582 $dbh->rollback if $oldAutoCommit;
586 else { delete($contact_emails{$email}); }
590 foreach my $contact_email ( $self->contact_email ) {
591 if ($contact_emails{$contact_email->emailaddress}) {
592 my $error = $contact_email->delete;
594 $dbh->rollback if $oldAutoCommit;
602 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
603 #warn " queueing fuzzyfiles update\n"
605 $error = $self->queue_fuzzyfiles_update;
607 $dbh->rollback if $oldAutoCommit;
608 return "updating fuzzy search cache: $error";
612 if ( $cust_contact and (
613 ( $cust_contact->selfservice_access eq ''
614 && $link_hash{selfservice_access}
615 && ! length($self->_password)
617 || $cust_contact->_resend()
621 my $error = $self->send_reset_email( queue=>1 );
623 $dbh->rollback if $oldAutoCommit;
628 if ( $self->get('password') ) {
629 my $error = $self->is_password_allowed($self->get('password'))
630 || $self->change_password($self->get('password'));
632 $dbh->rollback if $oldAutoCommit;
637 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
643 =item _parse_phonestring PHONENUMBER_STRING
645 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
646 with keys 'countrycode', 'phonenum' and 'extension'
648 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
652 sub _parse_phonestring {
655 my($countrycode, $extension) = ('1', '');
658 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
664 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
668 ( 'countrycode' => $countrycode,
669 'phonenum' => $value,
670 'extension' => $extension,
674 =item queue_fuzzyfiles_update
676 Used by insert & replace to update the fuzzy search cache
680 use FS::cust_main::Search;
681 sub queue_fuzzyfiles_update {
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';
691 my $oldAutoCommit = $FS::UID::AutoCommit;
692 local $FS::UID::AutoCommit = 0;
695 foreach my $field ( 'first', 'last' ) {
696 my $queue = new FS::queue {
697 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
699 my @args = "contact.$field", $self->get($field);
700 my $error = $queue->insert( @args );
702 $dbh->rollback if $oldAutoCommit;
703 return "queueing job (transaction rolled back): $error";
707 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
714 Checks all fields to make sure this is a valid contact. If there is
715 an error, returns the error, otherwise returns false. Called by the insert
723 if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'P' ) {
724 $self->selfservice_access('Y');
729 $self->ut_numbern('contactnum')
730 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
731 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
732 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
733 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
734 || $self->ut_namen('last')
735 || $self->ut_namen('first')
736 || $self->ut_textn('title')
737 || $self->ut_textn('comment')
738 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
739 || $self->ut_textn('_password')
740 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
741 || $self->ut_enum('disabled', [ '', 'Y' ])
743 return $error if $error;
745 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
747 return "One of first name, last name, or title must have a value"
748 if ! grep $self->$_(), qw( first last title);
755 Returns a formatted string representing this contact, including name, title and
762 my $data = $self->first. ' '. $self->last;
763 $data .= ', '. $self->title
765 $data .= ' ('. $self->comment. ')'
772 Returns a formatted string representing this contact, with just the name.
778 $self->first . ' ' . $self->last;
781 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
783 #Returns the name of this contact's class for the specified prospect or
784 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
785 #L<FS::contact_class>).
789 #sub contact_classname {
790 # my( $self, $prospect_or_cust ) = @_;
793 # if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
794 # $link = qsearchs('prospect_contact', {
795 # 'contactnum' => $self->contactnum,
796 # 'prospectnum' => $prospect_or_cust->prospectnum,
798 # } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
799 # $link = qsearchs('cust_contact', {
800 # 'contactnum' => $self->contactnum,
801 # 'custnum' => $prospect_or_cust->custnum,
804 # croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
807 # my $contact_class = $link->contact_class or return '';
808 # $contact_class->classname;
811 #autoloaded by FK in 4.x, but not during the upgrade
814 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
817 =item by_selfservice_email EMAILADDRESS
819 Alternate search constructor (class method). Given an email address, returns
820 the contact for that address. If that contact doesn't have selfservice access,
821 or there isn't one, returns the empty string.
825 sub by_selfservice_email {
826 my($class, $email) = @_;
828 my $contact_email = qsearchs({
829 'table' => 'contact_email',
830 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
831 'hashref' => { 'emailaddress' => $email, },
833 AND ( contact.disabled IS NULL )
834 AND EXISTS ( SELECT 1 FROM cust_contact
835 WHERE contact.contactnum = cust_contact.contactnum
836 AND cust_contact.selfservice_access = 'Y'
841 $contact_email->contact;
845 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
846 # and should maybe be libraried in some way for other password needs
848 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
850 sub authenticate_password {
851 my($self, $check_password) = @_;
853 if ( $self->_password_encoding eq 'bcrypt' ) {
855 my( $cost, $salt, $hash ) = split(',', $self->_password);
857 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
859 salt => de_base64($salt),
865 $hash eq $check_hash;
869 return 0 if $self->_password eq '';
871 $self->_password eq $check_password;
877 =item change_password NEW_PASSWORD
879 Changes the contact's selfservice access password to NEW_PASSWORD. This does
880 not check password policy rules (see C<is_password_allowed>) and will return
881 an error only if editing the record fails for some reason.
883 If NEW_PASSWORD is the same as the existing password, this does nothing.
887 sub change_password {
888 my($self, $new_password) = @_;
890 # do nothing if the password is unchanged
891 return if $self->authenticate_password($new_password);
893 $self->change_password_fields( $new_password );
899 sub change_password_fields {
900 my($self, $new_password) = @_;
902 $self->_password_encoding('bcrypt');
906 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
908 my $hash = bcrypt_hash( { key_nul => 1,
916 join(',', $cost, en_base64($salt), en_base64($hash) )
921 # end of false laziness w/FS/FS/Auth/internal.pm
924 #false laziness w/ClientAPI/MyAccount/reset_passwd
925 use Digest::SHA qw(sha512_hex);
927 use FS::ClientAPI_SessionCache;
928 sub send_reset_email {
929 my( $self, %opt ) = @_;
931 my @contact_email = $self->contact_email or return '';
933 my $reset_session = {
934 'contactnum' => $self->contactnum,
935 'svcnum' => $opt{'svcnum'},
939 my $conf = new FS::Conf;
941 ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
943 my $reset_session_id;
945 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
946 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
949 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
954 my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
955 $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
957 my $agentnum = $cust_main ? $cust_main->agentnum : '';
958 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
959 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
960 return "selfservice-password_reset_msgnum unset" unless $msgnum;
961 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
962 return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template;
964 'to' => join(',', map $_->emailaddress, @contact_email ),
965 'cust_main' => $cust_main,
967 'substitutions' => { 'session_id' => $reset_session_id }
970 if ( $opt{'queue'} ) { #or should queueing just be the default?
972 my $cust_msg = $msg_template->prepare( %msg_template );
973 my $error = $cust_msg->insert;
974 return $error if $error;
975 my $queue = new FS::queue {
976 'job' => 'FS::cust_msg::process_send',
977 'custnum' => $cust_main ? $cust_main->custnum : '',
979 $queue->insert( $cust_msg->custmsgnum );
983 $msg_template->send( %msg_template );
989 use vars qw( $myaccount_cache );
990 sub myaccount_cache {
992 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
993 'namespace' => 'FS::ClientAPI::MyAccount',
997 =item cgi_contact_fields
999 Returns a list reference containing the set of contact fields used in the web
1000 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
1001 and locationnum, as well as password fields, but including fields for
1002 contact_email and contact_phone records.)
1006 sub cgi_contact_fields {
1009 my @contact_fields = qw(
1010 classnum first last title comment emailaddress selfservice_access
1011 invoice_dest message_dest password
1014 push @contact_fields, 'phonetypenum'. $_->phonetypenum
1015 foreach qsearch({table=>'phone_type', order_by=>'weight'});
1021 use FS::upgrade_journal;
1022 sub _upgrade_data { #class method
1023 my ($class, %opts) = @_;
1025 # before anything else, migrate contact.custnum to cust_contact records
1026 unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) {
1028 local($skip_fuzzyfiles) = 1;
1030 foreach my $contact (qsearch('contact', {})) {
1031 my $error = $contact->replace;
1032 die $error if $error;
1035 FS::upgrade_journal->set_done('contact_invoice_dest');
1039 # always migrate cust_main_invoice records over
1040 local $FS::cust_main::import = 1; # override require_phone and such
1041 my $search = FS::Cursor->new('cust_main_invoice', {});
1043 while (my $cust_main_invoice = $search->fetch) {
1044 my $custnum = $cust_main_invoice->custnum;
1045 my $dest = $cust_main_invoice->dest;
1046 my $cust_main = $cust_main_invoice->cust_main;
1048 if ( $dest =~ /^\d+$/ ) {
1049 my $svc_acct = FS::svc_acct->by_key($dest);
1050 die "custnum $custnum, invoice destination svcnum $svc_acct does not exist\n"
1052 $dest = $svc_acct->email;
1054 push @{ $custnum_dest{$custnum} ||= [] }, $dest;
1056 my $error = $cust_main_invoice->delete;
1058 die "custnum $custnum, cleaning up cust_main_invoice: $error\n";
1062 foreach my $custnum (keys %custnum_dest) {
1063 my $dests = $custnum_dest{$custnum};
1064 my $cust_main = FS::cust_main->by_key($custnum);
1065 my $error = $cust_main->replace( invoicing_list => $dests );
1067 die "custnum $custnum, creating contact: $error\n";
1079 L<FS::Record>, schema.html from the base documentation.