Design Rationale — Crm

Non-obvious design choices for the crm module — the WHY behind each decision.

CRM (locked 2026-06-09; additive touch 2026-06-10)

One customer table with customer_type discriminator

Decision: Individual and business customers share one customer table with a customer_type discriminator. B2B-specific fields (company name, tax ID ref, credit terms) are nullable on the shared table.

Why: Merge-same-thing — both are customers with the same FK targets (tenant, site, group, address, note, consent), same RLS policy, and the same checkout/order lifecycle. The variation is a handful of nullable columns.

Rejected: Separate individual_customer and business_customer tables — would require every cross-module reference to account for two tables; the CRM query path would fork everywhere.


email is NOT unique; deduplication is a managed customer_merge process

Decision: No unique constraint on customer.email. Duplicate customers are handled by the explicit customer_merge entity.

Why: Real-world duplicates happen legitimately: a walk-in customer created at POS later creates an online account with the same email. A hard unique would block creation and break offline POS (which can't check uniqueness against the server). The right answer is a reconciliation workflow, not a constraint.

Guard: The absent email unique is deliberate — do not add it. Any "fix" that adds a unique constraint on email will break offline POS creation and block legitimate duplicate scenarios.


Decision: Consent changes are recorded in customer_consent (append-only event log). customer.marketing_opt_in is a maintained cache of the current consent state.

Why: GDPR/CCPA compliance requires the full history of consent changes (who changed it, when, what they consented to). Checkout and marketing tooling need the current flag fast without scanning the consent log.

Guard: marketing_opt_in is derived from customer_consent — it is a cache, not the source of truth. If they diverge, the consent log wins. Do not update marketing_opt_in without a corresponding consent log entry.


customer.tax_exempt is a cache; customer_tax_certificate is the evidence

Decision: customer.tax_exempt (bool) is a maintained cache: true iff the customer has at least one active, non-expired customer_tax_certificate row. The cert table is the auditable evidence. customer.tax_exempt_id (text) is deprecated — cert numbers now live on customer_tax_certificate.certificate_number.

Why: A tax exemption flag must be backed by an actual certificate for audit. Without the cert table, "exempt = true" is unauditable. The cache exists for fast checkout queries; the cert table is the source of truth for compliance.

Guard: customer.tax_exempt is derived — do not treat it as authoritative for compliance purposes. Any code path that grants a tax exemption must verify a valid cert row exists, not just read the boolean. tax_exempt_id must not receive new writes — it exists only for backward compatibility.


Last modified: Jun 17, 2026, 8:37 PM PT
On this page
Esc