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 --allConfiguration comes from api-modules/seeder/.env:
| Var | Purpose |
|---|---|
SOURCE_DATABASE_URL | Legacy beautybarn Postgres (read-only source) |
DATABASE_URL | Target supercommerce Postgres |
MONGO_URL | Id-map store (idempotency / resume) |
SEED_VENDOR_ID | The single target vendor every legacy product/order is attributed to |
ORDER_MONEY_SCALE | Optional. 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-runThe 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):
- has a usable password (can log in with credentials)
- email was verified
- most orders (
total_orders) - 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.status — pending_payment (PENDING_PAYMENT, DRAFT) · cancelled (CANCELLED, FAILED) · confirmed (everything else).
order.payment_status — refunded (order REFUNDED, or refund ≥ amount) · partially_refunded (0 < refund < amount) · paid (payment PAID, or a delivered/completed COD order) · failed · pending.
order_vendor.fulfillment_status — returned · 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:
- migrates every legacy transaction as a ledger row (preserving the history it does have),
- sets
available_balanceto the authoritative legacypointsvalue (the true current balance), and - 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
| Domain | Status |
|---|---|
| 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_conditionare unused — applicability is the direct product/category/brand/tag links. Targeting is expanded from product-level to variant-level (discount_variantetc.); source include vs exclude tables collapse onto themode(INCLUDE/EXCLUDE) column. FIXEDvalueand order-amount thresholds scale ×100; PERCENTAGEvalueis a whole percent. - FREE_GIFT-type discounts are coupon-based free gifts, not money discounts
(the target
discountenum is PERCENTAGE/FIXED only). They share acodewith aCOUPON_BASEDgift rule, so they are migrated through the free-gift domain (the rule carriescouponCode), not as discounts. Theirdiscount_usagesrows are zero-value duplicates of the matchinggift_usage_logsand 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 singlefree_gift_rulerow; buy / gift / filter sides become the variant-level link tables. Soft-deleted rules are excluded.free_gift_rule.nameis 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.
- Rewards —
SUM(reward_customer_state.available_balance)should equal the per-customerSUM(reward_ledger.points). - Counts — compare source vs target row counts per domain; orders with a null
customer_idare the email-less/tombstone customers (expected).