Supercommerce API Docs
Guides & Operations

Beautybarn Data Migration

How the @sc/seeder one-shot migrator moves customers, orders, rewards, reviews and wishlists from the legacy beautybarn (Prisma/Postgres) database into supercommerce, including the same-email customer merge, password preservation, money scaling and the order-status crosswalk.

The @sc/seeder package (api-modules/seeder) performs a one-shot migration of the live legacy beautybarn store into supercommerce. It reads the legacy Postgres database directly (raw SQL), writes the supercommerce schema via Drizzle, and records a { entity, sourceId, targetId } id-map in MongoDB so every run is idempotent and resumable.

Catalog (brands, categories, tags, ingredients, products, variants, attributes, content) is migrated by the original catalog migrators. This guide covers the customer + commerce domains added on top: customers, addresses, orders, returns, rewards, reviews and wishlists.

How to run

cd api-modules/seeder
bun ./src/index.ts --migrator=customer,address,review,order,order-return,reward,wishlist,device-token
# or run everything (catalog + commerce) in dependency order:
bun ./src/index.ts --all

Configuration comes from api-modules/seeder/.env:

VarPurpose
SOURCE_DATABASE_URLLegacy beautybarn Postgres (read-only source)
DATABASE_URLTarget supercommerce Postgres
MONGO_URLId-map store (idempotency / resume)
SEED_VENDOR_IDThe single target vendor every legacy product/order is attributed to
ORDER_MONEY_SCALEOptional. Force 100 (rupees→paise) or 1; auto-probed otherwise

Run order matters and is enforced by the migrator list: customer → address → review → order → order-return → reward → wishlist → device-token. Reviews precede rewards (reward transactions reference review ids); orders precede returns and rewards (which reference order ids).

Dry run

The customer migrator supports --dry-run: it reports the duplicate-email groups, the chosen survivor per group, and the merge counts without writing anything.

bun ./src/index.ts --migrator=customer --dry-run

The customer merge (the core idea)

Beautybarn has a dedicated customers table where email is NOT unique — the same person registered/checked out repeatedly under one email across many rows. Supercommerce has no customer table: a storefront customer is a better-auth user, and user.email is UNIQUE + NOT NULL. So every set of same-email source customers must collapse into one user.

The merge is implemented as an id-map collapse: the customer migrator maps every source customer.id (the survivor and all its duplicates) to the same deterministic target user.id. Every downstream migrator then resolves customer_id through the id-map, so duplicates collapse automatically — reward balances sum, and orders / addresses / reviews / wishlists all land on the one user. This mirrors the legacy customer-merge.service.ts (reassign relations → keep one survivor).

Survivor selection (highest priority first):

  1. has a usable password (can log in with credentials)
  2. email was verified
  3. most orders (total_orders)
  4. oldest account (created_at) as the final tie-break

Profile fields (name, phone, avatar) come from the survivor, with nulls backfilled from the duplicates. user.phone_number is unique platform-wide, so the survivor keeps its phone and conflicting phones are dropped.

Eligibility. Only live customers with a usable email become users: deleted_at IS NULL, a non-blank email, and not a legacy merge tombstone (merged-…@deleted.user). Customers with no usable email do not become users; their orders still migrate, with customer_id left null and the email/address preserved on the order snapshot.

Passwords

Migrated customers keep their original password hash verbatim in account.password, so nobody is forced to reset. Beautybarn hashed customer passwords with PHPass (the WordPress/phpBB portable hash, $P$ / $H$ prefix). The auth module's better-auth config sniffs the hash prefix at login and routes:

  • $P$ / $H$ → the ported PHPass verify (api-modules/auth/src/lib/legacy-password.ts, a bit-identical port of beautybarn's hasher)
  • everything else → better-auth's native scrypt

New and changed passwords use scrypt. OAuth customers get an account row for their provider instead of a credential.

Money

All legacy money is stored in whole rupees; supercommerce stores integer paise (minor units). Order, payment, return and redemption amounts are scaled ×100. The scale is proven against live data before any amount is written: the migrator compares a sample of order_item.unit_price to the originating product_variants.price and aborts if amounts already look like paise. Override with ORDER_MONEY_SCALE only when the probe can't decide on sparse data.

Order status crosswalk

The legacy flat 14-value OrderStatusEnum (plus a separate fulfillment_status and payment.status) is split across the three independent supercommerce axes:

order.statuspending_payment (PENDING_PAYMENT, DRAFT) · cancelled (CANCELLED, FAILED) · confirmed (everything else).

order.payment_statusrefunded (order REFUNDED, or refund ≥ amount) · partially_refunded (0 < refund < amount) · paid (payment PAID, or a delivered/completed COD order) · failed · pending.

order_vendor.fulfillment_statusreturned · cancelled · delivered (DELIVERED/COMPLETED) · fulfilled (FULFILLED/PARTIALLY_FULFILLED) · pending. Each order becomes a single order_vendor sub-order under the seed vendor.

Order numbers preserve the legacy sequence: ORD-<year of order date>-<display_id padded to 8>, with display_id kept in metadata.legacyDisplayId. The order_number_seq is bumped past the max legacy id after the run so new orders can't collide.

Returns (return_orders) migrate into order_return (+ lines and photos) with their own status crosswalk, decoupled from order.status.

Rewards

The legacy model is a flat points balance per customer plus an ADD/REMOVE transaction log. Supercommerce uses an event-sourced ledger with a projected balance. Rather than reconstructing earn-lots (the source never tracked them), each transaction becomes a manual_credit / manual_debit ledger row, and the projected reward_customer_state.available_balance is set to the authoritative summed source balance (across all of a merged user's accounts). Points are counts (no money scaling), rounded to integers.

The "Balance reconciliation (legacy migration)" ledger entries

The legacy reward_transactions log is incomplete — it records earns but is missing many of the redemptions / expirations / admin adjustments that reduced a customer's balance. So for most customers the sum of recorded transactions is higher than their actual points balance (e.g. a customer with a real balance of 29 may have 1,029 in recorded earns). The target ledger is the source of truth and its rows MUST sum to the projected balance, so the migrator:

  1. migrates every legacy transaction as a ledger row (preserving the history it does have),
  2. sets available_balance to the authoritative legacy points value (the true current balance), and
  3. inserts one reconciling ledger row per customer — labelled Balance reconciliation (legacy migration) — that closes the gap so the ledger sums to that real balance.

These reconciliation rows are usually negative (the legacy log over-counted) and are expected, not an error. They are what guarantees a customer's migrated spendable balance exactly equals their legacy balance — without them, customers would be credited points they never actually held. Every customer's balance is authoritative; the reconciliation row is the bookkeeping that makes the event-sourced ledger consistent with it.

What is and isn't migrated

DomainStatus
Customers → users (+ accounts, addresses)✅ with same-email merge
Orders (+ sub-order, lines, payments, audit events)
Order returns (+ lines, photos)
Rewards (accounts + transactions → ledger + state)
Product reviews (+ images)
Wishlists (+ items)
Discounts (PERCENTAGE / FIXED) + targeting + usages
Free-gift rules (automatic / buy-x-get-y / coupon) + usages
Device / push tokens⏭️ Skipped — legacy rows carry no customer_id (anonymous app-install push tokens); user_device requires a user, so there is nowhere to attach them. Apps re-register their token on next launch.

Discounts & free gifts

  • Source discount_rule / discount_condition are unused — applicability is the direct product/category/brand/tag links. Targeting is expanded from product-level to variant-level (discount_variant etc.); source include vs exclude tables collapse onto the mode (INCLUDE/EXCLUDE) column. FIXED value and order-amount thresholds scale ×100; PERCENTAGE value is a whole percent.
  • FREE_GIFT-type discounts are coupon-based free gifts, not money discounts (the target discount enum is PERCENTAGE/FIXED only). They share a code with a COUPON_BASED gift rule, so they are migrated through the free-gift domain (the rule carries couponCode), not as discounts. Their discount_usages rows are zero-value duplicates of the matching gift_usage_logs and are skipped to avoid double-counting redemptions.
  • The normalized source gift sub-tables (buy_x_get_y_gifts, automatic_gifts, coupon_gifts, gift_criteria, gift_filters, gift_restrictions) flatten into the single free_gift_rule row; buy / gift / filter sides become the variant-level link tables. Soft-deleted rules are excluded. free_gift_rule.name is unique, so colliding names get a legacy-id suffix.

Idempotency & resume

Target rows use deterministic ids derived from the source key (e.g. user:<emailKey>, order:<sourceOrderId>), inserted with ON CONFLICT DO NOTHING. A re-run after an interruption reproduces the exact same ids and no-ops what's already done — so the migration is safe to re-run end-to-end. High-volume stages stream the source by keyset, insert in chunked batches, and bulk-write the id-map.

Legacy Json? columns are sometimes double-encoded (a JSON string) and can contain lone UTF-16 surrogates; the migrator normalizes these (asObject + sanitizeJson / cleanText) before writing jsonb, so dirty user-entered data can't break the run.

Verification

After a run, sanity-check on the target:

  • Merge — pick a known duplicate email; confirm it resolved to one user whose orders and summed reward balance match the combined source records.
  • RewardsSUM(reward_customer_state.available_balance) should equal the per-customer SUM(reward_ledger.points).
  • Counts — compare source vs target row counts per domain; orders with a null customer_id are the email-less/tombstone customers (expected).

On this page