← 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

  • 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.
  • Because the ledger is the source of truth, a balance is always reconstructable — including across a POS migration.
  • Hold legacy export read-only for 90 days post-cutover — disputes resolve against it.

Frequently asked

When we switch POS systems, why do customers keep telling budtenders their points balance is wrong?
Most cannabis-POS loyalty setups store points as a single counter on the customer row, so on migration you copy that number and trust the math. It breaks when one POS computes points differently, tier-history isn't exported, the sign-up-date field is named differently, or a mid-migration sync leaves rows inconsistent. Once there's no history, reconstruction is impossible after a discrepancy gets reported, and the customer notices before the till opens.
How do I move an old points balance over without copying it wrong?
Treat the legacy balance as a single migrate-in ledger row rather than a counter copied verbatim, with points set to the legacy balance plus a source_system label and a migration_batch_id. That makes the cutover reversible, since you can re-run the migrate-in if it fails, and auditable, since every customer's ledger has a clear pre-migration boundary. Validate the export first so total points match the legacy POS's aggregate report, then run a reconciliation report the day after cutover expecting a match within plus or minus 5 points per customer.
A customer disputes their pre-migration balance weeks later - how do I resolve it?
Hold the legacy export read-only for 90 days after cutover, and treat it as the source of truth for any pre-migration value in dispute. Because current balance is computed from the append-only ledger rather than stored on the customer row, you can run the SUM, find the discrepancy if one exists, and either confirm the number or credit it with a documented reason. That turns the old 5-minute apology-and-manager-credit conversation into about a 30-second answer.

Related guides

Ready to talk through your migration?

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