Xendit Sandbox onto catalog_product

A per-org converter migrates 300 Xendit fee products onto a composite-price model and threads them through the publish seam — without changing behavior for any other org.

PR dealops#6091 Author @pk675 Branch pk/xendit-catalog-migration Files 17 +/− +1974 / −54 Area Dealops 3 · catalog_product DB writes none

What it adds
A per-org xendit-sandbox converter + dry-run migration + round-trip verifier under dealops3/catalogProduct/migration/orgs/, plus an orgSlug → converter dispatch registry.
What it changes
The publish seam (v3Admin/_helpers.ts, publishDrafts.ts) routes V3→V2 through the registry instead of hardcoding convertDealopsCatalogToV2PricingSpec. Default branch is byte-identical to today.
What it preserves
No DB writes. No publish. Every un-registered org is unchanged. The shared pricingSpec converter gains additive passthroughs (4 calc fields + 5 envelope keys) that were previously dropped.
Registry / dispatch
SELL → composite_price
COGS → customAttributes
Derived from spec
Publish seam

1. Why this exists

Xendit Sandbox runs on Dealops 2 today: 305 V2 productSpecs and 1342 datapoints describing payment fee structures. Each product carries up to six fee components — list and cost variants of a processing fee, a rate, and a flat fee. The goal is to move this catalog onto the Dealops 3 catalog_product data model so that the V3 admin / catalog UI can manage it, without disturbing the live V2 publish path for any other org.

This PR is the output of the roundtrip-catalog-migration skill (also added on this branch). It is the code-only stage: converter + migration + verifier + the publish-seam wiring. No drafts are written and no catalog is published. Both of those are deliberately separate, explicit, later steps.

The proof-of-correctness gate is the round trip. verify.ts runs through the dispatched publish seam (not a parallel copy) and confirms that 300 well-formed products + 1316 datapoints reconstruct 1-1, with only the engine-neutral attributes mirror and regenerated datapoint ids differing.

2. The data model

One SKU per Xendit productId. Up to six fee components are split between price.composite_price (sell side) and flat scalar keys in customAttributes (cost side). The cost split exists because the catalog price column is sell-only and the attribute map allows only scalar values.

V2 productId — 6 fee datapoints

productIdid-cards-visa PROCESSING_FEEcurrency 2.50 IDR LIST_RATEpercent 2.9 LIST_FLAT_FEEcurrency 1000 IDR COGS_PROCESSING_FEEcurrency 1.20 IDR COGS_RATEpercent 1.5 COGS_FLAT_FEEcurrency 500 IDR
Plus selector tags pricebookId, pricingMetric, cogsMetric on every row.

catalog_product — one SKU

skuIdid-cards-visa baseProductIdcards price.composite_price.components[]SELL 3 entries customAttributes.cogsProcessingFeeCOST 1.20 customAttributes.cogsRateCOST 1.5 customAttributes.cogsFlatFeeCOST 500 pricebookIdDERIVED from spec pricingMetric / cogsMetricDERIVED from spec

The pricebookId and metric tags are not stored per component. They were verified constant across each product's datapoints (zero mismatch in the source data), so they live once on the spec and get re-injected on rebuild. The COGS currency is reconstructed from the product's listPriceCurrencies[0]; a guard in the planner fails the migration loudly if any product's COGS currency disagrees with its list currency — no silent USD fallback.

Not the Dealops model. This is not sku == datapoint (Dealops 1) and not the productFamily collapse (Dealops Voice AI). Each Xendit productId stays its own V2 productSpec; no productFamily is ever stamped, so the collapse step is a no-op for this org.

2.1 The fee taxonomy at a glance

V2 selectorTags.typeValue shapeSideStorage in catalog_product
PROCESSING_FEEcurrencySELLprice.composite_price.components[]
LIST_RATEpercentSELLprice.composite_price.components[]
LIST_FLAT_FEEcurrencySELLprice.composite_price.components[]
COGS_PROCESSING_FEEcurrency (number only)COSTcustomAttributes.cogsProcessingFee
COGS_RATEpercentCOSTcustomAttributes.cogsRate
COGS_FLAT_FEEcurrency (number only)COSTcustomAttributes.cogsFlatFee

3. The publish seam — a default-preserving dispatch

The publish path used to hardcode one converter. Two call sites in trpc/router/v3Admin/ are now routed through getOrgCatalogConverter(orgSlug), which returns the existing shared behavior for every org except those explicitly registered.

Before
// _helpers.ts
const v2SpecData =
  convertDealopsCatalogToV2PricingSpec(v3Spec);

// publishDrafts.ts
const derived = deriveV3Datasheet(...);
await syncDataSheetToV2AsNewVersion(
  organizationId, derived, userId, msg
);

One converter for everyone. Dealops Voice AI needs the collapse; Xendit's datasheet types throw inside deriveV3Datasheet.

After
// _helpers.ts
const v2SpecData =
  getOrgCatalogConverter(orgSlug).pricingSpecToV2(v3Spec);

// publishDrafts.ts
const v2Datapoints =
  getOrgCatalogConverter(orgSlug).catalogToV2Datapoints({
    orgId: organizationId,
    liveSpecs: await loadLiveProductSpecs(organizationId),
    baseDatasheet: baseDatasheet as V3DataSheet,
  });
await syncV2DatapointsAsNewVersion(
  organizationId, v2Datapoints, userId, msg
);

Default returns the shared collapse-aware converter and the shared datasheet path. Registered orgs override.

The registry

// migration/_utils/registry.ts
const DEFAULT_CONVERTER: OrgCatalogConverter = {
  pricingSpecToV2: convertDealopsCatalogToV2PricingSpec,
  catalogToV2Datapoints: ({ orgId, liveSpecs, baseDatasheet }) =>
    convertToV2DataSheet(deriveV3Datasheet(orgId, liveSpecs, baseDatasheet)),
};

const REGISTRY: Record<string, OrgCatalogConverter> = {
  'xendit-sandbox': {
    pricingSpecToV2: xenditCatalogToV2PricingSpec,
    catalogToV2Datapoints: xenditCatalogToV2Datapoints,
  },
};

export function getOrgCatalogConverter(orgSlug: string | null | undefined) {
  return (orgSlug && REGISTRY[orgSlug]) || DEFAULT_CONVERTER;
}

A new helper, syncV2DatapointsAsNewVersion, was extracted so a per-org converter can produce V2 datapoints directly instead of going through the V3 datasheet model. This is the seam Xendit needs because its fee types can't pass through the shared deriveV3Datasheet.

4. The Xendit converter

The forward path (V2 → catalog rows) is in planXenditCatalogRows; the reverse path (catalog → V2) splits into xenditCatalogToV2PricingSpec (shared, no-collapse) and xenditCatalogToV2Datapoints (rebuilds the six fee datapoints from price + customAttributes).

Three safety guards

Guard #1 — write refuses on incomplete catalog
assertWritablePlan throws when --write is used while products are excluded. Because publish re-sources V2 from the live catalog, an incomplete write would silently delete the excluded products from V2. Caller must pass --allow-exclusions to acknowledge the drop.
Guard #2 — no silent drop of unknown fee types
detectBuggyProducts flags any datapoint whose selectorTags.type is unknown, missing, or sits on a non-listPrice page. The whole product is excluded with a visible reason rather than collapsing into an empty SKU.
Guard #3 — COGS currency must match
Because COGS is stored as a flat number, the reverse rebuilds the currency from listPriceCurrencies[0]. The planner asserts every COGS currency already equals that, so a hand-edited USD-on-IDR-product can never silently change currency at publish.

All three guards have hard-guard unit-test coverage in xenditCatalogConverter.test.ts — pure, in-memory, no DB. Sample assertions:

// #1
assert.throws(
  () => assertWritablePlan(planWith(2), /* allowExclusions */ false),
  /Refusing to --write an incomplete catalog: 2 product/,
);

// #2 — unknown type is EXCLUDED, never silently dropped
const plan = planXenditCatalogRows({ v2Spec: specWith('USD'),
  v2Datapoints: [sellDp('USD'), dp('WEIRD_FEE', cur(1, 'USD'), {...})] });
assert.strictEqual(plan.rows.length, 0);
assert.ok(plan.excluded[0].reasons.some(r => r.startsWith('unsupported-type:')));

// #3 — IDR list product with USD COGS fails planning
assert.throws(
  () => planXenditCatalogRows({ v2Spec: specWith('IDR'),
    v2Datapoints: [sellDp('IDR'), cogsDp('USD')] }),
  /COGS currency mismatch/,
);

5. The five excluded products

Five Xendit products have malformed source data — datapoints missing pricebookId or their metric tag, or sitting on pageId: 'cogs'. The migration excludes them by design and surfaces them for data cleanup, instead of pretending they round-tripped.

productIdDisplay name
id-data-product-npwp-validator-advanceData Product
73443788-e0a2-49c0-a354-82953d37d3c5QR Code (<350 ticket size)
id-cards-bni-installments-12-monthsCards
id-cards-bri-installments-3-monthsCards
id-cards-bni-installments-6-monthsCards

6. Shared additive extensions

A literal 1-1 round trip required four fields that the V2↔V3 pricingSpec converter was silently dropping. They are now carried verbatim, guarded so an org that never set them does not gain a default.

Calc-spec fields carried through
  • rampedQliMode
  • tieredQliMode
  • disableQuotePriceEdit
  • enableRampedQuotePrice

Schema is .optional() with no default — orgs that never set these stay clean.

Envelope keys carried through
  • cogsSpec
  • compositeSpec
  • productFormSpec
  • productTagsSpec
  • productSpecUniqueInvariance

Schema is z.any().optional() passthrough — matching the other envelope fields.

The composite-component value type also gains a percent member, so LIST_RATE components (and only those — the cost-side rate stays a flat scalar) can be persisted in price.composite_price:

// catalogProduct/price/types.ts
export const componentValueSchema = z.union([
  numberCurrencySchema,
  numberPercentSchema,        // ← new
  tieredCurrencyValueSchema,
]);

7. Client: edit drawer round-trips COGS and tolerates model-less products

The catalog edit drawer needed two adjustments so a Xendit SKU survives a save without corrupting the migrated row.

COGS hydration
A new cogsFromDto reads the three flat keys (cogsRate, cogsProcessingFee, cogsFlatFee) into the form's cost section. On save, the inverse writes each present numeric back and deletes removed components. Non-COGS orgs see an undefined block and are unaffected.
Model-less product handling
The form defaults pricingModel to 'flat', which isn't a valid V2 PricingModel. For a Xendit SKU (no editable pricing model), the new code skips writing pricingModel entirely and keeps the migrated revenue flags via the overlay, instead of dereferencing the now-undefined buildCalculationSpecForProductInput result.
KIND_TO_NAME extended
PROCESSING_FEE, LIST_RATE, LIST_FLAT_FEE get human names that exactly equal compositeSpec.allowedComponentOptions names, so PricingSection.resolveOption matches by name and rows render correctly.

On the V2 sync side, the V3→V2 converter also drops pricingModel when it isn't a valid V2 PricingModel, using PricingModelSchema.safeParse as the gate:

// pricingSpec/v2Converter.ts
...(PricingModelSchema.safeParse(attrs.pricingModel).success && {
  pricingModel: attrs.pricingModel as CalculationSpec['pricingModel'],
}),

8. Operational guardrails on the publish transaction

publishDrafts.ts already wraps the org-level flip in a single interactive transaction. With ~300 SKUs, one create/update per SKU over Neon latency was running past Prisma's 5s default. The fix raises the ceiling rather than batching:

await prisma.$transaction(
  async (tx) => { /* lock org, flip drafts to live, etc. */ },
  // big first publish (~hundreds of SKUs) can exceed Prisma's 5s default
  { timeout: 120_000, maxWait: 20_000 },
);

Smaller catalogs finish well under the ceiling and are unaffected. The comment explicitly flags createMany batching as a follow-up.

9. The verifier

verify.ts is the proof. It is read-only and DB-free: it consumes a directory containing the org's V2 pricingSpec.json and datasheet.json and runs:

  1. planXenditCatalogRows → catalog rows (excluding the 5 buggy products).
  2. rowToValidatedProductSpec on every row — the same schema gate loadLiveProductSpecs applies at publish.
  3. getOrgCatalogConverter('xendit-sandbox') for both reverse calls — the actual dispatched seam, not a parallel copy.
  4. Canonical-JSON diff over the clean products' specs, and by-selectorTags diff over the clean datapoints.

Diffs are classified explicitly. An "attributes mirror only" product diff counts as acceptable (the engine-neutral mirror that convertToV2ProductSpec adds to every product). A regenerated datapoint id counts as cosmetic (the engine matches on selectorTags, never the id). Anything else is must-resolve.

Verdict on the source dump: 300/300 clean products pass; the only spec-level diffs are the attributes mirror. All 1316 clean datapoints round-trip with identical selectorTags + value; the only datapoint diffs are regenerated ids. pnpm typecheck clean; pnpm test:mocha green at 5006 passing including the Dealops default-path round-trip.

10. What this PR doesn't change

11. Risks and open questions

Hardcoded 'xendit-sandbox' string in the verifier. verify.ts calls getOrgCatalogConverter('xendit-sandbox') directly. Fine for a per-org script, but the slug is duplicated between the registry key and the verifier — if the org slug ever changes, both must be updated together.
Preview-quote divergence is a real gap. Until createPreviewQuote.ts is routed through the registry, any feature that previews a Xendit quote will throw inside the shared deriveV3Datasheet. Tracked in the PR description but worth confirming before the org is flipped to V3.
Rollback is clean. Because nothing is written and the registry default reproduces today's behavior, reverting this PR is a no-op for every running org. Even if the registry is left in place but Xendit isn't registered, the publish seam falls back to the shared converter.