diff --git a/dashboard/lib/razorpay-product-sync.ts b/dashboard/lib/razorpay-product-sync.ts index 2d2787f..0531dcb 100644 --- a/dashboard/lib/razorpay-product-sync.ts +++ b/dashboard/lib/razorpay-product-sync.ts @@ -8,6 +8,7 @@ export interface RazorpayPriceInput { export interface RazorpaySyncResult { planIdsByCurrency: Record // currency → plan_id (subscriptions) paymentLinksByCurrency: Record // currency → short_url (one-time / trial) + skippedCurrencies: string[] // currencies Razorpay rejected (e.g. USD on an INR-only account) } // PayCraft billing intervals as stored on tenant_products.interval. @@ -16,11 +17,13 @@ export type BillingInterval = "month" | "quarter" | "semiannual" | "year" /** * Map a PayCraft billing interval to a Razorpay Plan cadence. * - * Razorpay's `period` enum is { daily, weekly, monthly, quarterly, yearly } and - * the cadence between charges is `period × interval` (the multiplier). There is - * no native half-yearly period, so semiannual is expressed as monthly × 6. + * Razorpay's Plan `period` enum is ONLY { daily, weekly, monthly, yearly } — there + * is NO `quarterly` and no half-yearly. The cadence between charges is + * `period × interval` (the multiplier), so quarterly and semiannual are both + * expressed as monthly × N. Verified live: a `quarterly` period 400s with + * "Invalid argument for period passed", while monthly × 6 is accepted. * - * The previous mapping collapsed everything that wasn't "month" to yearly × 1, + * The original mapping collapsed everything that wasn't "month" to yearly × 1, * which silently created Pro Quarterly / Pro Semiannual plans that billed once a * year — a real revenue mismatch. We map each interval explicitly and throw on * anything unrecognised rather than guess (the caller reports it as a failed @@ -28,14 +31,14 @@ export type BillingInterval = "month" | "quarter" | "semiannual" | "year" */ function razorpayPlanCadence( interval: BillingInterval | string | null, -): { period: "monthly" | "quarterly" | "yearly"; multiplier: number } { +): { period: "monthly" | "yearly"; multiplier: number } { switch (interval) { case "month": return { period: "monthly", multiplier: 1 } case "quarter": - return { period: "quarterly", multiplier: 1 } + return { period: "monthly", multiplier: 3 } case "semiannual": - return { period: "monthly", multiplier: 6 } // Razorpay has no half-yearly period + return { period: "monthly", multiplier: 6 } case "year": return { period: "yearly", multiplier: 1 } default: @@ -62,63 +65,79 @@ export async function syncProductToRazorpay( const client = await getConnectedRazorpayClient(tenantId, mode) const planIdsByCurrency: Record = { ...existingPlanIds } const paymentLinksByCurrency: Record = {} + const skippedCurrencies: string[] = [] + + // Razorpay rejects currencies the account isn't enabled for (e.g. USD on an + // INR-only account) with a 400 "Currency provided is not supported". We skip + // such currencies and keep going so supported currencies (INR) still sync; + // ANY other error (bad period, auth, etc.) is a real failure and propagates. + const isUnsupportedCurrencyError = (e: any): boolean => + /currency/i.test(String(e?.error?.description ?? e?.message ?? "")) for (const { currency, amountCents } of prices) { const ccyKey = currency.toUpperCase() - if (productType === "subscription" && interval) { - // Skip if plan already exists for this currency. - if (planIdsByCurrency[ccyKey]) continue + try { + if (productType === "subscription" && interval) { + // Skip if plan already exists for this currency. + if (planIdsByCurrency[ccyKey]) continue - const { period, multiplier } = razorpayPlanCadence(interval) - const plan = await (client as any).plans.create({ - period, - interval: multiplier, - item: { - name: productName, - amount: amountCents, - currency: ccyKey, - description: productName, - }, - notes: { - paycraft_tenant_id: tenantId, - paycraft_product_id: productId, - }, - }) - planIdsByCurrency[ccyKey] = plan.id + const { period, multiplier } = razorpayPlanCadence(interval) + const plan = await (client as any).plans.create({ + period, + interval: multiplier, + item: { + name: productName, + amount: amountCents, + currency: ccyKey, + description: productName, + }, + notes: { + paycraft_tenant_id: tenantId, + paycraft_product_id: productId, + }, + }) + planIdsByCurrency[ccyKey] = plan.id - // Create a subscription link (Razorpay's equivalent of a payment link for subscriptions) - try { - const link = await (client as any).subscriptionRegistration.createRegistrationLink({ - type: "link", + // Create a subscription link (Razorpay's equivalent of a payment link for subscriptions) + try { + const link = await (client as any).subscriptionRegistration.createRegistrationLink({ + type: "link", + amount: amountCents, + currency: ccyKey, + description: productName, + subscription_registration: { + method: "emandate", + auth_type: "netbanking", + }, + notify: { sms: true, email: true }, + }) + paymentLinksByCurrency[ccyKey] = link.short_url + } catch { + // Subscription links are optional — don't fail the whole sync + } + } else { + // One-time payment link for lifetime / trial products + const link = await (client as any).paymentLink.create({ amount: amountCents, currency: ccyKey, description: productName, - subscription_registration: { - method: "emandate", - auth_type: "netbanking", - }, notify: { sms: true, email: true }, + notes: { + paycraft_tenant_id: tenantId, + paycraft_product_id: productId, + }, }) paymentLinksByCurrency[ccyKey] = link.short_url - } catch { - // Subscription links are optional — don't fail the whole sync } - } else { - // One-time payment link for lifetime / trial products - const link = await (client as any).paymentLink.create({ - amount: amountCents, - currency: ccyKey, - description: productName, - notify: { sms: true, email: true }, - notes: { - paycraft_tenant_id: tenantId, - paycraft_product_id: productId, - }, - }) - paymentLinksByCurrency[ccyKey] = link.short_url + } catch (e: any) { + if (isUnsupportedCurrencyError(e)) { + skippedCurrencies.push(ccyKey) + continue + } + throw e } } - return { planIdsByCurrency, paymentLinksByCurrency } + return { planIdsByCurrency, paymentLinksByCurrency, skippedCurrencies } } diff --git a/dashboard/lib/stripe-route-helper.ts b/dashboard/lib/stripe-route-helper.ts index e46d233..9bd8f6e 100644 --- a/dashboard/lib/stripe-route-helper.ts +++ b/dashboard/lib/stripe-route-helper.ts @@ -120,6 +120,19 @@ export async function razorpaySyncProduct( p_payment_links: result.paymentLinksByCurrency, }), ]) + + // Nothing landed and every currency was rejected by Razorpay → tell the + // operator exactly what to do (Razorpay is INR-first; USD-only products need + // an INR price, or International payments enabled on the Razorpay account). + const created = + Object.keys(result.planIdsByCurrency).length + + Object.keys(result.paymentLinksByCurrency).length + if (created === 0 && result.skippedCurrencies.length > 0) { + return { + ok: false, + error: `Razorpay does not accept ${result.skippedCurrencies.join(", ")} for this account. Add an INR price for this product (or enable International payments on Razorpay).`, + } + } return { ok: true } } catch (e: any) { // The Razorpay SDK rejects with a PLAIN OBJECT { statusCode, error: { code,