Skip to content

Contact Systeem

Het contact systeem is verantwoordelijk voor het aanmaken, matchen, normaliseren en synchroniseren van klantgegevens. Contacten worden aangemaakt via formulieren (WPForms) of via de REST API, en gesynchroniseerd met Moneybird via Make.com webhooks.

Formulier submit / API call
Contact matching (4 prioriteiten)
├─ Match gevonden → update
└─ Geen match → create
Data normalisatie (email, telefoon, postcode, naam)
ACF velden opslaan
acf/save_post trigger
Make.com webhook (als niet maasdelta + niet website-origin)
Moneybird contact sync

Via Formulier (hra_upsert_contact_from_wpforms)

Section titled “Via Formulier (hra_upsert_contact_from_wpforms)”
Input: contact velden uit WPForms submission
Gate check: minstens company OF firstname OF lastname gevuld
└─ Alles leeg → skip, return 0
Stap 1: Direct match via WP Contact ID
├─ Form 40: field 78 (file-contact)
├─ Form 3493: field 79 (job-contact)
└─ Als gevonden → update title als gewijzigd → return contact_id
Stap 2: Fallback matching (geen direct ID)
a. Zoek contacten op email (meta_query: contact-email)
b. Voor elke kandidaat, bereken:
- Identity key: lowercase(company) OF lowercase(firstname+lastname)
- Address key: normalized(zipcode) + normalized(number) + normalized(addition)
c. Match criteria:
- Identity key matcht EN address key matcht → MATCH
- Enkele kandidaat met lege identity → geaccepteerd
- Enkele kandidaat met lege address → geaccepteerd
d. Meerdere matches → neem eerste (geen scoring)
Stap 3: Geen match → nieuw contact
├─ wp_insert_post(['post_type' => 'contact', 'post_status' => 'publish'])
├─ Title: "Company" of "Firstname Lastname" of "Contact"
├─ Set _acs_origin = 'website' (voor sync regels)
└─ return contact_id

Via REST API (acs_job_api_find_contact_id)

Section titled “Via REST API (acs_job_api_find_contact_id)”

Gebruikt in POST /wp-json/acs/v1/jobs/create:

PrioriteitMatch MethodeMeta Query
4moneybird_idmeta_key=moneybird_id, meta_value=$id
3Adres + emailpostcode + huisnr + toevoeging + email (4 conditions)
2Alleen adrespostcode + huisnr + toevoeging (3 conditions)
1Alleen emailemail match
0Geen matchacs_job_api_create_contact()

De found_by waarde wordt meegegeven in de API response.

VeldFunctieTransformatieVoorbeeld
Emailhra_norm_email()strtolower(trim())" Test@Email.nl ""test@email.nl"
Naamhra_norm_name()strtolower() + whitespace collapse"Jan de Vries""jan de vries"
Postcodehra_norm_postcode()strtoupper + spaties verwijderen"1234 ab""1234AB"
Typehra_norm_type()strtolower(trim())" Maasdelta ""maasdelta"
Whitespacehra_norm_ws()trim() + collapse spaces" hello world ""hello world"
Straatnaamhra_street_from_select_value()Strip ||source + (City) suffix"Hoofdstraat||maasdelta""Hoofdstraat"
function hra_norm_email(string $e): string {
return strtolower(trim($e));
}
function hra_norm_postcode(string $s): string {
$s = strtoupper(trim($s));
return preg_replace('/\s+/', '', $s) ?? '';
}
function hra_street_from_select_value(string $value): string {
$v = trim($value);
if ($v === '') return '';
$parts = explode('||', $v);
$v = trim((string)($parts[0] ?? $v));
// Strip suffix zoals " (Hellevoetsluis)"
$v = preg_replace('/\s*\([^)]*\)\s*$/u', '', $v);
return trim((string)$v);
}

Contact matching gebruikt samengestelde keys voor vergelijking:

// Identity key: bedrijf OF persoon
function hra_submitted_identity_key(string $company, string $firstname, string $lastname): string {
$company = hra_norm_name($company);
$firstname = hra_norm_name($firstname);
$lastname = hra_norm_name($lastname);
if ($company !== '') return 'c|' . $company; // "c|loodgieter bv"
$full = trim($firstname . ' ' . $lastname);
return 'p|' . $full; // "p|jan de vries"
}
// Address key: alle adresvelden samengevoegd
function hra_contact_address_key(array $a): string {
return implode('|', [
hra_norm_ws($a['streetname'] ?? ''),
hra_norm_ws((string)($a['number'] ?? '')),
hra_norm_ws((string)($a['addition'] ?? '')),
hra_norm_postcode((string)($a['zipcode'] ?? '')),
hra_norm_ws((string)($a['city'] ?? '')),
]);
// Resultaat: "hoofdstraat|42|a|1234AB|rotterdam"
}
SituatieGedrag
Meerdere contacten met zelfde emailEerste match op identity+address wint
1 contact met email, identity+address leegGeaccepteerd als enige kandidaat
Email leegFallback matching overgeslagen, direct naar create
Company EN naam leegGate check faalt, return 0 (geen contact)
Contact ID veld gevuld maar post verwijderdFallthrough naar email matching
Bestaand contact, titel gewijzigdwp_update_post() update alleen titel

Bestand: wpforms-normalize-phone.php Functie: my_normalize_phone_nl_to_e164()

Stappen:

  1. Strip alle niet-cijfer/+ tekens
  2. Detecteer format:
    • +31XXXXXXXXX → geldig E.164, behoud
    • 0031XXXXXXXXX → vervang 00 door ++31...
    • 06XXXXXXXX → vervang 0 door +31+316XXXXXXXX
    • 0XX-XXXXXXX → vervang 0 door +31+31XX...
  3. Als directe normalisatie faalt → check stadsnaam voor netnummer:
NetnummerSteden
0181Hellevoetsluis, Spijkenisse + 25 plaatsen
0187Stellendam
010Rotterdam
  1. Als netnummer gevonden EN telefoon lijkt lokaal (geen +/00/0 prefix):
    • Combineer: netnummer + lokale cijfers → retry normalisatie
  2. Return E.164 format of lege string

City slug functie: my_slugify_city() — lowercase, strip diacritics, verwijder interpunctie.

Bestand: modal-search-contact.js (29.8KB) + form-search-contact.php

  • “Zoek klant” knop onder contact velden:
    • Form 40: onder field 77
    • Form 3493: onder field 78
  • Knop verborgen wanneer velden vergrendeld zijn (prefill/contact geselecteerd)
  • Select2 met REST API backend: GET /wp-json/acs/v1/contacts
  • Parameters: search, page, per_page
  • Paginatie ingebouwd in Select2
  • Resultaten tonen: bedrijf/naam + adres

Na selectie van een contact:

  1. fetchContactById()GET /wp-json/acs/v1/contacts/{id}
  2. Preview toont: bedrijf, volledige naam, email, telefoon, volledig adres
  3. “Contact selecteren” knop verschijnt

Wanneer gebruiker “Contact selecteren” klikt:

1. BEAM_CONTACT_PREFILL_BUSY = true (pauzeer lock cycle)
2. Vul contact velden:
Form 40: 78 (id), 68 (company), 69 (firstname), 70 (lastname),
71 (email), 82 (phone), 80 (moneybird_id)
Form 3493: 79 (id), 70 (company), 67 (firstname), 68 (lastname),
69 (email), 83 (phone), 82 (moneybird_id)
3. Adres resolution via AJAX (mode=resolve_full):
├─ Gevonden → vul Select2 velden (23, 24, 25) + auto-fill (26, 27)
│ → activeer display mode (readonly adres weergave)
└─ Niet gevonden → fallback naar custom address input
4. Apply .beam-lock-applied op alle contact veld containers
5. Toon "Bewerk" knop
6. Maak contact summary (via BEAM_CREATE_CONTACT_SUMMARY callback)
7. BEAM_CONTACT_PREFILL_BUSY = false
8. Sluit modal
  • Maakt readonly display divs voor straat, nummer, toevoeging
  • Voegt .beam-addr-display-mode class toe aan containers
  • Verbergt Select2 dropdowns
  • “Bewerk” knop → verwijdert display mode, ontgrendelt velden

Bestand: wpforms-add-or-update-contact.php (12.6KB)

Hoofdfunctie: hra_upsert_contact_from_wpforms

Section titled “Hoofdfunctie: hra_upsert_contact_from_wpforms”

Parameters: $fields (WPForms entry data), $form_data (form config)

Alleen voor Forms: 40 en 3493

Veld extractie per formulier:

VeldForm 40 Field IDForm 3493 Field ID
Type7476
Company6870
Firstname6967
Lastname7068
Email7169
Phone8283
Street1723
Number5224
Addition1925
Zipcode2026
City2127
Note2828

Bij match (update) of nieuw contact:

  • contact-type, contact-firstname, contact-lastname, contact-company
  • contact-email (genormaliseerd), contact-phone
  • contact-zipcode, contact-streetname, contact-number, contact-addition, contact-city
  • contact-note

do_action('acf/save_post', $contact_id) — eenmaal per contact per request (static cache).

hra_upsert_contact_from_wpforms_cached():

  • Wrapper met static cache per form_id:entry_id
  • Voorkomt dubbele upserts wanneer meerdere hooks dezelfde form data verwerken

hra_set_relationship_field($post_id, $acf_field_name, $related_id):

  • Checkt ACF field_object voor multiple flag
  • Als multiple → wrap ID in array
  • Fallback naar update_post_meta als ACF veld niet gevonden

Bestand: form-field-locks.js (14.4KB)

ScenarioTriggerResultaat
URL prefillURL params aanwezigAlle contact + adres velden vergrendeld
Contact geselecteerdContact ID field heeft waardeContact velden vergrendeld
Geen van beideLeeg formulierNiets vergrendeld, zoekknop zichtbaar
VeldtypeLock Methode
Text inputsreadonly property
Checkboxes/radiosclick event prevented
Select2Opening disabled, tabindex prevented, invisible overlay
Containers.beam-lock-applied class
Elementendata-beamLocked attribute
  • Contact: 78, 68, 69, 70, 71, 82 (id, company, firstname, lastname, email, phone)
  • Extra: 22, 80, 2, 15, 14, 13, 4 (custom toggle, moneybird, custom address velden)
  • Select: 23, 24, 25 (straat, nummer, toevoeging — alleen als niet ontgrendeld)
  1. “Bewerk” knop klik
  2. Fires beam:unlock:{formId} custom event
  3. Verwijdert alle locks en classes
  4. Toont zoek knop
  5. Re-enabled lazy Select2 voor adres chain
  6. Sets BEAM_WPF40_UNLOCKED = true
  • Polling: setInterval(250ms) voor lock cycle check
  • MutationObserver op form voor dynamische content
  • Pauze: BEAM_CONTACT_PREFILL_BUSY stopt de cycle tijdens contact prefill

Samenvatting — volledige documentatie in Integraties.

  • acf/save_post op contact CPT (priority 20)
  • POST naar Make.com webhook URL
  • created: eerste save (post_date === post_modified)
  • updated: volgende saves
ConditieReden
contact-type === 'maasdelta'Woningcorporatie, nooit syncen
_acs_origin === 'website' zonder job/fileVoorkom sync van incomplete contacten
Debounce actief (3 sec transient)Voorkom dubbele webhooks
Niet productie environmentAlleen live webhooks
  1. Make.com ontvangt webhook → maakt Moneybird contact
  2. Moneybird retourneert moneybird_id
  3. Make roept POST /wp-json/acs/v1/contacts/upsert aan
  4. moneybird_id wordt opgeslagen op contact in WordPress
  5. Volgende matching kan nu op moneybird_id (hoogste prioriteit)

Na match of creatie worden alle velden bijgewerkt via een centrale map:

$acf_map = [
'type' => 'contact-type',
'firstname' => 'contact-firstname',
'lastname' => 'contact-lastname',
'company' => 'contact-company',
'email' => 'contact-email',
'phone' => 'contact-phone',
'zipcode' => 'contact-zipcode',
'streetname' => 'contact-streetname',
'number' => 'contact-number',
'addition' => 'contact-addition',
'city' => 'contact-city',
'note' => 'contact-note',
];
foreach ($acf_map as $var => $meta_key) {
$val = $$var; // Dynamische variabele
if ($val !== '') {
beam_update_post_meta($contact_id, $meta_key, $val);
}
}
$full_name = trim($firstname . ' ' . $lastname);
if (!empty($company)) {
$title = $company; // "Loodgieter BV"
} elseif (!empty($full_name)) {
$title = $full_name; // "Jan de Vries"
} else {
$title = 'Contact'; // Fallback
}

Bij update: titel wordt alleen bijgewerkt als deze verschilt van de huidige.

In de nieuwe Beam stack wordt het contact systeem vervangen door een Supabase contacts tabel met directe queries (geen WP_Query, geen ACF meta). Zie Migratie naar Beam Stack voor het voorgestelde schema en de RLS policies.

BestandLocatieFunctie
wpforms-add-or-update-contact.phpfunctions/Contact upsert + matching + linking
wpforms-normalize-phone.phpfunctions/Telefoon E.164 normalisatie
form-search-contact.phpfunctions/Contact zoek modal (PHP + enqueue)
modal-search-contact.jsassets/js/Contact zoek modal (client-side)
form-field-locks.jsassets/js/modules/Field locking na selectie
api-contact.phpfunctions/REST API + webhook sync
api-job.phpfunctions/Contact lookup bij job creatie
wpforms-field-mappings.phpfunctions/Field ID ↔ ACF key mapping