Contact Systeem
Overzicht
Section titled “Overzicht”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 syncContact Matching Algoritme
Section titled “Contact Matching Algoritme”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_idVia 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:
| Prioriteit | Match Methode | Meta Query |
|---|---|---|
| 4 | moneybird_id | meta_key=moneybird_id, meta_value=$id |
| 3 | Adres + email | postcode + huisnr + toevoeging + email (4 conditions) |
| 2 | Alleen adres | postcode + huisnr + toevoeging (3 conditions) |
| 1 | Alleen email | email match |
| 0 | Geen match | → acs_job_api_create_contact() |
De found_by waarde wordt meegegeven in de API response.
Data Normalisatie
Section titled “Data Normalisatie”Normalisatie Functies
Section titled “Normalisatie Functies”| Veld | Functie | Transformatie | Voorbeeld |
|---|---|---|---|
hra_norm_email() | strtolower(trim()) | " Test@Email.nl " → "test@email.nl" | |
| Naam | hra_norm_name() | strtolower() + whitespace collapse | "Jan de Vries" → "jan de vries" |
| Postcode | hra_norm_postcode() | strtoupper + spaties verwijderen | "1234 ab" → "1234AB" |
| Type | hra_norm_type() | strtolower(trim()) | " Maasdelta " → "maasdelta" |
| Whitespace | hra_norm_ws() | trim() + collapse spaces | " hello world " → "hello world" |
| Straatnaam | hra_street_from_select_value() | Strip ||source + (City) suffix | "Hoofdstraat||maasdelta" → "Hoofdstraat" |
Implementatie
Section titled “Implementatie”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);}Identity & Address Keys
Section titled “Identity & Address Keys”Contact matching gebruikt samengestelde keys voor vergelijking:
// Identity key: bedrijf OF persoonfunction 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 samengevoegdfunction 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"}Edge Cases bij Matching
Section titled “Edge Cases bij Matching”| Situatie | Gedrag |
|---|---|
| Meerdere contacten met zelfde email | Eerste match op identity+address wint |
| 1 contact met email, identity+address leeg | Geaccepteerd als enige kandidaat |
| Email leeg | Fallback matching overgeslagen, direct naar create |
| Company EN naam leeg | Gate check faalt, return 0 (geen contact) |
| Contact ID veld gevuld maar post verwijderd | Fallthrough naar email matching |
| Bestaand contact, titel gewijzigd | wp_update_post() update alleen titel |
Telefoon Normalisatie (E.164)
Section titled “Telefoon Normalisatie (E.164)”Bestand: wpforms-normalize-phone.php
Functie: my_normalize_phone_nl_to_e164()
Stappen:
- Strip alle niet-cijfer/+ tekens
- Detecteer format:
+31XXXXXXXXX→ geldig E.164, behoud0031XXXXXXXXX→ vervang00door+→+31...06XXXXXXXX→ vervang0door+31→+316XXXXXXXX0XX-XXXXXXX→ vervang0door+31→+31XX...
- Als directe normalisatie faalt → check stadsnaam voor netnummer:
| Netnummer | Steden |
|---|---|
| 0181 | Hellevoetsluis, Spijkenisse + 25 plaatsen |
| 0187 | Stellendam |
| 010 | Rotterdam |
- Als netnummer gevonden EN telefoon lijkt lokaal (geen +/00/0 prefix):
- Combineer: netnummer + lokale cijfers → retry normalisatie
- Return E.164 format of lege string
City slug functie: my_slugify_city() — lowercase, strip diacritics, verwijder interpunctie.
Contact Search Modal
Section titled “Contact Search Modal”Bestand: modal-search-contact.js (29.8KB) + form-search-contact.php
Openen
Section titled “Openen”- “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)
Zoeken
Section titled “Zoeken”- 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
Preview
Section titled “Preview”Na selectie van een contact:
fetchContactById()→GET /wp-json/acs/v1/contacts/{id}- Preview toont: bedrijf, volledige naam, email, telefoon, volledig adres
- “Contact selecteren” knop verschijnt
Toepassen op Formulier
Section titled “Toepassen op Formulier”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 modalAddress Display Mode
Section titled “Address Display Mode”- Maakt readonly display divs voor straat, nummer, toevoeging
- Voegt
.beam-addr-display-modeclass toe aan containers - Verbergt Select2 dropdowns
- “Bewerk” knop → verwijdert display mode, ontgrendelt velden
Contact Upsert
Section titled “Contact Upsert”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:
| Veld | Form 40 Field ID | Form 3493 Field ID |
|---|---|---|
| Type | 74 | 76 |
| Company | 68 | 70 |
| Firstname | 69 | 67 |
| Lastname | 70 | 68 |
| 71 | 69 | |
| Phone | 82 | 83 |
| Street | 17 | 23 |
| Number | 52 | 24 |
| Addition | 19 | 25 |
| Zipcode | 20 | 26 |
| City | 21 | 27 |
| Note | 28 | 28 |
ACF Velden Geschreven
Section titled “ACF Velden Geschreven”Bij match (update) of nieuw contact:
contact-type,contact-firstname,contact-lastname,contact-companycontact-email(genormaliseerd),contact-phonecontact-zipcode,contact-streetname,contact-number,contact-addition,contact-citycontact-note
Trigger
Section titled “Trigger”do_action('acf/save_post', $contact_id) — eenmaal per contact per request (static cache).
Static Cache
Section titled “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
Relationship Linking
Section titled “Relationship Linking”hra_set_relationship_field($post_id, $acf_field_name, $related_id):
- Checkt ACF
field_objectvoormultipleflag - Als multiple → wrap ID in array
- Fallback naar
update_post_metaals ACF veld niet gevonden
Field Locking
Section titled “Field Locking”Bestand: form-field-locks.js (14.4KB)
Lock Scenarios
Section titled “Lock Scenarios”| Scenario | Trigger | Resultaat |
|---|---|---|
| URL prefill | URL params aanwezig | Alle contact + adres velden vergrendeld |
| Contact geselecteerd | Contact ID field heeft waarde | Contact velden vergrendeld |
| Geen van beide | Leeg formulier | Niets vergrendeld, zoekknop zichtbaar |
Lock Mechanisme
Section titled “Lock Mechanisme”| Veldtype | Lock Methode |
|---|---|
| Text inputs | readonly property |
| Checkboxes/radios | click event prevented |
| Select2 | Opening disabled, tabindex prevented, invisible overlay |
| Containers | .beam-lock-applied class |
| Elementen | data-beamLocked attribute |
Vergrendelde Velden (Form 40)
Section titled “Vergrendelde Velden (Form 40)”- 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)
Ontgrendelen
Section titled “Ontgrendelen”- “Bewerk” knop klik
- Fires
beam:unlock:{formId}custom event - Verwijdert alle locks en classes
- Toont zoek knop
- Re-enabled lazy Select2 voor adres chain
- Sets
BEAM_WPF40_UNLOCKED = true
Timing
Section titled “Timing”- Polling:
setInterval(250ms)voor lock cycle check - MutationObserver op form voor dynamische content
- Pauze:
BEAM_CONTACT_PREFILL_BUSYstopt de cycle tijdens contact prefill
Webhook Sync
Section titled “Webhook Sync”Samenvatting — volledige documentatie in Integraties.
Trigger
Section titled “Trigger”acf/save_postop contact CPT (priority 20)- POST naar Make.com webhook URL
Events
Section titled “Events”- created: eerste save (post_date === post_modified)
- updated: volgende saves
Skip Conditions
Section titled “Skip Conditions”| Conditie | Reden |
|---|---|
contact-type === 'maasdelta' | Woningcorporatie, nooit syncen |
_acs_origin === 'website' zonder job/file | Voorkom sync van incomplete contacten |
| Debounce actief (3 sec transient) | Voorkom dubbele webhooks |
| Niet productie environment | Alleen live webhooks |
Moneybird ID Terugkoppeling
Section titled “Moneybird ID Terugkoppeling”- Make.com ontvangt webhook → maakt Moneybird contact
- Moneybird retourneert
moneybird_id - Make roept
POST /wp-json/acs/v1/contacts/upsertaan moneybird_idwordt opgeslagen op contact in WordPress- Volgende matching kan nu op
moneybird_id(hoogste prioriteit)
ACF Velden Update (Detail)
Section titled “ACF Velden Update (Detail)”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); }}Titel Bepaling
Section titled “Titel Bepaling”$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.
Migratie
Section titled “Migratie”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.
Bestandsoverzicht
Section titled “Bestandsoverzicht”| Bestand | Locatie | Functie |
|---|---|---|
| wpforms-add-or-update-contact.php | functions/ | Contact upsert + matching + linking |
| wpforms-normalize-phone.php | functions/ | Telefoon E.164 normalisatie |
| form-search-contact.php | functions/ | Contact zoek modal (PHP + enqueue) |
| modal-search-contact.js | assets/js/ | Contact zoek modal (client-side) |
| form-field-locks.js | assets/js/modules/ | Field locking na selectie |
| api-contact.php | functions/ | REST API + webhook sync |
| api-job.php | functions/ | Contact lookup bij job creatie |
| wpforms-field-mappings.php | functions/ | Field ID ↔ ACF key mapping |