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 --allRun 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:
| 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 → … → 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-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
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:
| Value | Effect |
|---|---|
unset / scrypt | Default. better-auth's native scrypt (N=16384, r=16, p=1, dkLen=64, 16-byte salt, stored as salt:key hex). |
phpass | The 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.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) | ✅ |
| 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:
| Legacy | Target |
|---|---|
inventory_quantity | quantity_on_hand (negatives — a handful of oversold rows — clamp to 0, which the quantity_on_hand >= 0 check requires) |
manage_inventory | track_inventory (false ⇒ the variant reads as untracked and is always orderable, matching legacy) |
allow_back_order | allow_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 cart | Target status | Timestamps |
|---|---|---|
| The winner (one per user) | active | last_activity_at = migration time |
completed_at set | converted | converted_at = completed_at |
| Everything else (losers, guest, buy-now, temporary, soft-deleted) | abandoned | last_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_itemrows with acart_idbecomecart_line.product_idandvendor_idare read off the migrated variant (legacyline_item.product_idis 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_uidxincludesfree_gift_rule_id, and Postgres treats NULLs there as distinct, so duplicates would otherwise slip through. cart_discount→cart_applied_coupon, withcoderead from the legacydiscountsrow. 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()callscartGiftService.reconcile()on every read, so a gift that no longer qualifies is stripped before it can reach checkout. - Guest carts (
customer_idNULL) are preserved for analytics but are not reachable by the storefront — they're keyed by a legacy cookie the new storefront never sends. The legacyemail, carttypeandorder_noteare kept undercart.metadata(legacyEmail,legacyType,legacyOrderNote).
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).
Sign in with Apple — setup guide
Turning on Sign in with Apple for a deployment: the Apple Developer portal setup, the API's environment (including how to pass the .p8 key), the storefront, and how the Flutter app integrates it on iOS and Android.
Docker
Production container images for the three deployable apps: