I used to stash the Polar product id on the plan row and treat it like a slug. Then a second provider showed up with pdt_* ids, Polar kept prod_*, and a leftover Dodo id almost went out on a Polar checkout.
ts
const mapped = await this.planRepository.findExternalProductId(
planId,
providerId,
);
if (mapped) {
return mapped;
}
if (providerId === 'dodo' && fallback) {
return fallback;
}
throw new ApiException(
ApiErrorCode.BAD_REQUEST,
HttpStatus.BAD_REQUEST,
'error.plan_not_sellable',
);
Polar fail-closes. No mapping, no sell. Dodo can still use the legacy column. The UI does the same cut: Polar only offers rows that list Polar in billingProviders.
ts
export function canOfferInAppPlanChange(offer, billingProvider) {
const provider = billingProvider ?? DEFAULT_BILLING_PROVIDER;
if (provider !== "polar") return true;
if (offer.billingProviders == null) {
return offer.slug != null && isKnownPricingPlanSlug(offer.slug);
}
return offer.billingProviders.includes("polar");
}
builder and agency are marketing names. Polar never sees them. The mapping table is the only join between those two ids.
Keep the catalog in your database. Polar's id is a foreign key.
