← All guides

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.

By CannAgent6 min read

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 typeWhat it capturesWhen it fires
earn+points, transaction id, multiplier (1x / 2x / promo)Every transaction with loyalty-eligible customer
redeem-points, transaction id, redemption tierCustomer redeems at counter
adjust+/- points, manager id, reason textManager-PIN-gated correction
migrate-in+points, source-system label, migration batch idOne-time on cutover from prior POS
expire-points, expiry-policy citationIf/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).

  1. **Export legacy balances as CSV** with customer_email + current_points + tier + signup_date + last_earn_at. One row per customer.
  2. **Validate the export** — total points across all customers should match the legacy POS’s aggregate report. If not, find the discrepancy before cutover.
  3. **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.
  4. **Migrate tier-history** as separate tier_history table with achieved_at timestamps preserved. Customer tier is derived from history; sign-up date stays original.
  5. **Run a reconciliation report** the day after cutover — total points should match within ±5 per customer (rounding tolerance). Anything beyond requires investigation.
  6. **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

Ready to talk through your migration?

30-minute demo. We end by quoting the cutover from your current setup — fixed scope, no hourly games.