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.
xendit-sandbox converter + dry-run migration + round-trip verifier under dealops3/catalogProduct/migration/orgs/, plus an orgSlug → converter dispatch registry.
v3Admin/_helpers.ts, publishDrafts.ts) routes V3→V2 through the registry instead of hardcoding convertDealopsCatalogToV2PricingSpec. Default branch is byte-identical to today.
pricingSpec converter gains additive passthroughs (4 calc fields + 5 envelope keys) that were previously dropped.
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.
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
pricebookId, pricingMetric, cogsMetric on every row.
catalog_product — one SKU
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.
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.type | Value shape | Side | Storage in catalog_product |
|---|---|---|---|
PROCESSING_FEE | currency | SELL | price.composite_price.components[] |
LIST_RATE | percent | SELL | price.composite_price.components[] |
LIST_FLAT_FEE | currency | SELL | price.composite_price.components[] |
COGS_PROCESSING_FEE | currency (number only) | COST | customAttributes.cogsProcessingFee |
COGS_RATE | percent | COST | customAttributes.cogsRate |
COGS_FLAT_FEE | currency (number only) | COST | customAttributes.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.
// _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.
// _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
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.
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.
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.
| productId | Display name |
|---|---|
id-data-product-npwp-validator-advance | Data Product |
73443788-e0a2-49c0-a354-82953d37d3c5 | QR Code (<350 ticket size) |
id-cards-bni-installments-12-months | Cards |
id-cards-bri-installments-3-months | Cards |
id-cards-bni-installments-6-months | Cards |
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.
rampedQliModetieredQliModedisableQuotePriceEditenableRampedQuotePrice
Schema is .optional() with no default — orgs that never set these stay clean.
cogsSpeccompositeSpecproductFormSpecproductTagsSpecproductSpecUniqueInvariance
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.
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.
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.
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:
planXenditCatalogRows→ catalog rows (excluding the 5 buggy products).rowToValidatedProductSpecon every row — the same schema gateloadLiveProductSpecsapplies at publish.getOrgCatalogConverter('xendit-sandbox')for both reverse calls — the actual dispatched seam, not a parallel copy.- Canonical-JSON diff over the clean products' specs, and by-
selectorTagsdiff 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.
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
- No DB writes.
migrate.tsis dry-run by default;--writeupserts draft rows only on explicit invocation against Neon or localhost. - No publish. Promoting drafts to live is a separate
publishCatalogDraftsstep, not part of this PR. - No flip of
pricingFlowType.initV3.tsseeds the V3 versioning store but does not flip the org toDEALOPS_V3. - No behavior change for other orgs. The registry default returns today's shared converter + datasheet path. Dealops Voice AI keeps its collapse; un-registered orgs are byte-identical.
- No change to the preview-quote path.
createPreviewQuote.tsstill calls the sharedderiveV3Datasheetdirectly and would throw for Xendit until it is routed through the same registry — flagged as a follow-up. - The 5 buggy products are not migrated. They need source data cleanup first.
11. Risks and open questions
'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.
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.