Supercommerce API Docs
Guides & Operations

Beautybarn Data Migration

How the @sc/seeder one-shot migrator moves customers, orders, rewards, reviews, wishlists and carts from the legacy beautybarn (Prisma/Postgres) database into supercommerce, including the same-email customer merge, password preservation (and the AUTH_PASSWORD_HASH switch for writing legacy phpass hashes), 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, inventory, 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, wishlists and carts.

How to run

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

Run it with TZ=UTC. Every migrator hands legacy Date values straight to Drizzle, which serializes them to UTC before writing to the timestamp (no time zone) columns — so a seeder process running in a non-UTC zone shifts every migrated timestamp by the machine's offset (2026-03-01 00:00:00 becomes 2026-02-28 18:15:00 at UTC+05:45). This affects all domains, not just carts.

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 → … → cart. Reviews precede rewards (reward transactions reference review ids); orders precede returns and rewards (which reference order ids); cart runs last of the commerce domains because its lines resolve variants, its applied coupons resolve discounts and its gift lines resolve free-gift rules.

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

OAuth customers get an account row for their provider instead of a credential.

Which algorithm new passwords are written with

Verification is always prefix-sniffed, so both forms keep working no matter what we write. What gets written on sign-up, password reset and admin set-password is selected by the AUTH_PASSWORD_HASH env var, read once at auth-config time:

ValueEffect
unset / scryptDefault. better-auth's native scrypt (N=16384, r=16, p=1, dkLen=64, 16-byte salt, stored as salt:key hex).
phpassThe legacy portable hash instead — $P$B…, 34 chars, 2^13 md5 rounds, byte-identical to what beautybarn wrote.

Anything else logs a warning and falls back to scrypt rather than guessing.

phpass exists for one case: a legacy PHP system that is still live and must verify passwords changed here. It is a real downgrade — iterated MD5 rather than a memory-hard KDF — so it should be switched back to scrypt the moment that verifier is decommissioned. Because verification accepts both, flipping the value is safe in either direction and needs no migration; only passwords written while the flag was set carry the weaker hash, and each will upgrade itself the next time that user changes their password under scrypt.

Both directions are implemented in api-modules/auth/src/lib/legacy-password.ts (hashPhpassPassword / verifyPhpassPassword) off the same cryptPrivate core, and the selector lives in lib/password-hash-algorithm.ts. The generated form has been cross-checked against passlib's independent phpass implementation in both directions, including empty and multi-byte passwords.

Money

A legacy special_price of 0 means "no special price", not "free". Legacy uses 0 and NULL interchangeably for an unset special (125 live variants carry a 0 alongside a real price), so the variant migrator maps any non-positive special to NULL. Carried across literally it would price the product at zero: the storefront card resolves specialPrice ?? price, and only a NULL special falls through to the real price. price itself gets no such treatment — a 0 there is a genuine freebie.

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)
Carts (+ lines, applied coupons)✅ every cart, incl. guest and completed ones — see Carts
Inventory (per-variant stock)✅ see Inventory
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.

Inventory

Legacy has no stock table — stock lives on the variant row (product_variants.inventory_quantity, manage_inventory, allow_back_order). The inventory migrator runs immediately after variant and lifts those three columns into one inventory_stock row per migrated variant:

LegacyTarget
inventory_quantityquantity_on_hand (negatives — a handful of oversold rows — clamp to 0, which the quantity_on_hand >= 0 check requires)
manage_inventorytrack_inventory (false ⇒ the variant reads as untracked and is always orderable, matching legacy)
allow_back_orderallow_backorder

reserved_quantity, safety_stock_quantity and low_stock_threshold have no legacy counterpart and are owned by the target: a re-run refreshes quantity, tracking and backorder flags but never touches them, and on-hand is floored at GREATEST(source, reserved_quantity) so a delta pass during cutover can't strip a reservation the new system already holds. Each quantity change writes an inventory_movement (initial on first import, correction afterwards, referenceType = "migration"), so a re-run that changes nothing writes no movements.

Unlike every other migrator, this one ignores --since/--delta and always sweeps the full variant table. Legacy only bumps product_variants.updated_at on a full variant write, so a quantity moved through another code path would be invisible to a watermark pass — and stock is the one field a cutover cannot afford to miss.

Legacy inventory_reservation rows are not migrated: they belong to source orders that arrive as historical records, and the target creates reservations at checkout.

Carts

Every legacy cart row is migrated — customer and guest, live and completed — but only one per customer can come back to life, because cart_active_customer_uidx allows a single active cart per user and the same-email merge collapses several source customers onto one target user. The winner is picked per target user: the most recently updated cart that is not deleted, not completed, type = 'DEFAULT', temporary = false, and has at least one line.

Status mapping:

Legacy cartTarget statusTimestamps
The winner (one per user)activelast_activity_at = migration time
completed_at setconvertedconverted_at = completed_at
Everything else (losers, guest, buy-now, temporary, soft-deleted)abandonedlast_activity_at / abandoned_at = legacy updated_at

The active cart's clock is reset to the cutover deliberately: CartAbandonmentService.sweep() retires any active cart idle for more than CART_ABANDON_AFTER_MINUTES (24h), and findActiveForCustomer only returns active carts — with legacy timestamps every migrated cart would be swept before customers returned to it, and the sweep emits CART_ABANDONED → Klaviyo's "Cart Abandoned" metric, so it would also fire a recovery blast. Nothing is imported as discarded, since CartPurgeService hard-deletes that status after 90 days.

Lines, coupons and gifts:

  • line_item rows with a cart_id become cart_line. product_id and vendor_id are read off the migrated variant (legacy line_item.product_id is nullable and there is no vendor at all); a line whose variant didn't migrate is skipped. "unitPrice" is a quoted camelCase column in the legacy schema and scales ×100 like every other money field.
  • Duplicate lines for the same variant are merged, summing quantity. cart_line_unique_uidx includes free_gift_rule_id, and Postgres treats NULLs there as distinct, so duplicates would otherwise slip through.
  • cart_discountcart_applied_coupon, with code read from the legacy discounts row. Coupons whose discount didn't migrate (FREE_GIFT-type, which became gift rules) are skipped. Coupons that no longer validate are auto-removed by cart pricing on the next read.
  • GIFT lines carry over with their free_gift_rule_id; orphans (rule not migrated) are skipped. CartViewService.build() calls cartGiftService.reconcile() on every read, so a gift that no longer qualifies is stripped before it can reach checkout.
  • Guest carts (customer_id NULL) are preserved for analytics but are not reachable by the storefront — they're keyed by a legacy cookie the new storefront never sends. The legacy email, cart type and order_note are kept under cart.metadata (legacyEmail, legacyType, legacyOrderNote).

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