Customer-data deep-dive
Cannabis loyalty data — without losing a single point
Cannabis customers don’t check most of the data the operator obsesses over. They notice loyalty. Points balance off by 50, tier-history vanished, sign-up-date showing yesterday on a 4-year customer — they hit the budtender with it before the till opens. The fix is structural, not a one-time migration heroic. Append-only ledger, every earn + redeem + adjust as a row, never a counter.
Why loyalty data fails on migrations
Most cannabis-POS loyalty implementations store points as a single counter on the customer row. When you migrate, you SELECT the counter, INSERT into the new system, and trust the math. That works exactly until: (a) one POS computes points slightly differently than the other, (b) tier-history isn’t exported, (c) the sign-up-date field is named differently, or (d) a sync hiccup mid-migration leaves rows inconsistent. Then the customer notices. Then the budtender apologizes. Then operator-side trust erodes one customer at a time.
- **Counter-only storage** — single integer on the customer row. No history. Reconstruction impossible once a discrepancy is reported.
- **Tier-history flat-discarded** — Gold tier earned in 2022 turns into Silver after migration because nobody exported the tier-history table.
- **Sign-up date overwritten** — new POS uses createdAt for the customer record itself, not the original loyalty-enrollment date. 4-year customers look like new signups.
- **Mid-flight sync breaks** — POS-A is dual-running with POS-B for a week, points earn in both, neither knows about the other. Reconciliation is manual.
The append-only ledger pattern
Instead of a counter, store every loyalty event as a row: earn / redeem / adjust / migrate-in. The customer’s current balance is computed from the ledger, not stored on the customer row. Reconstruction is always possible because the history is the source of truth.
| Event type | What it captures | When it fires |
|---|---|---|
| earn | +points, transaction id, multiplier (1x / 2x / promo) | Every transaction with loyalty-eligible customer |
| redeem | -points, transaction id, redemption tier | Customer redeems at counter |
| adjust | +/- points, manager id, reason text | Manager-PIN-gated correction |
| migrate-in | +points, source-system label, migration batch id | One-time on cutover from prior POS |
| expire | -points, expiry-policy citation | If/when expiry policy fires |
Migrating from an existing POS
The trick to a clean migration is treating the legacy points balance as a single migrate-in row, not a counter copied verbatim. That makes the cutover reversible (we can re-run the migrate-in if it fails) and auditable (every customer’s ledger has a clear pre-migration boundary).
- **Export legacy balances as CSV** with customer_email + current_points + tier + signup_date + last_earn_at. One row per customer.
- **Validate the export** — total points across all customers should match the legacy POS’s aggregate report. If not, find the discrepancy before cutover.
- **Insert one migrate-in ledger row per customer** — points = legacy balance, source_system = 'dutchie' (or whatever), migration_batch_id = ISO date. The customer’s computed balance now matches.
- **Migrate tier-history** as separate tier_history table with achieved_at timestamps preserved. Customer tier is derived from history; sign-up date stays original.
- **Run a reconciliation report** the day after cutover — total points should match within ±5 per customer (rounding tolerance). Anything beyond requires investigation.
- **Hold the legacy export read-only for 90 days** post-cutover. If a customer disputes a balance, the legacy export is the source of truth for the pre-migration value. After 90 days the dispute window closes.
What customers notice — and what they don’t
- **Points balance discrepancy** — they always notice. Most-frequent dispute on any migration. Solved by accurate migrate-in + reconciliation.
- **Tier loss** — they notice if tier was earned + carried perks. Solved by migrating tier_history table separately.
- **Sign-up date** — they notice on anniversary milestones (5-year, 10-year). Solved by preserving original signup_date as a separate field.
- **Last-purchase date** — they don’t notice this directly, but the operator-side reorder math depends on it. Migrate as a side-table.
- **Earn rate / multiplier history** — they don’t notice unless the new POS computes earn differently. If the new system is 1pt/$1 and the legacy was 2pt/$1, that’s a re-anchor moment requiring customer communication, not silent migration.
Cannabis-specific quirks
- **WSLCB advertising rules (WAC 314-55-155)** — loyalty programs are permitted; what you can call them in customer-facing copy is regulated. Auto-generated emails / SMS need to clear the rules. Ledger structure doesn’t change this; the customer-facing surfacing does.
- **Industry discount + loyalty interaction (WAC 314-55-095)** — industry-discounted purchases may or may not earn loyalty points depending on operator policy. Document the rule in code, not just the SOP. Audit-log every variance from the rule.
- **Heroes / first-visit / birthday discounts** — these stack with loyalty in customer-readable ways. Make sure the ledger captures the discount line-item separately so the customer’s effective earn rate is reconstructable.
- **Federal banking + cash-only operations** — points-redemption-as-store-credit is a tax-accounting nuance (280E-adjacent). The ledger documenting the redemption is what your CPA needs at year-end; if the redemption was made in cash, capture both the points-out and the cash-equivalent value.
Takeaways
- Counter-only loyalty storage breaks under migration + sync hiccups + tier-history loss. Ledger pattern survives all three.
- Every loyalty event = a row: earn / redeem / adjust / migrate-in / expire. Customer balance is computed from the ledger, never stored on the customer row.
- Migration trick: treat legacy balance as ONE migrate-in row with source_system + migration_batch_id. Reversible + auditable.
- Dogfood result across our two stores: zero loyalty rows lost across 2 POS migrations.
- Hold legacy export read-only for 90 days post-cutover — disputes resolve against it.
Ready to talk through your migration?
30-minute demo. We end by quoting the cutover from your current setup — fixed scope, no hourly games.