diff --git a/CLAUDE.md b/CLAUDE.md index c5a1e04..74ec359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Two entry points, kept strictly separate: | Path | Surface | Notes | | --- | --- | --- | | `./client` | `SumitCheckout`, `useSumitCheckout`, `loadSumitPayments`, `createSingleUseToken` | Browser-only. Loads `payments.js` from SUMIT. **Card data never touches our server** — SUMIT's script reads form fields directly. | -| `./next` | `createSumitChargeRoute`, `createSumitWebhookRoute`, `verifySumitSharedSecret` | Server-only. Uses Web Standard `Request` / `Response` so it works in Edge and Node runtimes. | +| `./next` | `createSumitChargeRoute`, `createSumitWebhookRoute`, `verifySumitSharedSecret` | Server-only. Uses Web Standard `Request` / `Response` so it works in Edge and Node runtimes. `createSumitChargeRoute` accepts `mode: "recurring" \| "oneOff"` (default recurring) to switch between `/billing/recurring/charge/` and `/billing/payments/charge/`. | **Never import from `./next` in client code.** The server bundle holds the SUMIT `apiKey`; leaking it to the browser is a P0. diff --git a/README.md b/README.md index c7982a8..cedc828 100644 --- a/README.md +++ b/README.md @@ -107,22 +107,33 @@ import { createSumitChargeRoute } from "sumit-react/next"; export const POST = createSumitChargeRoute({ companyId: Number(process.env.SUMIT_COMPANY_ID), apiKey: process.env.SUMIT_API_KEY!, + // mode: "recurring" (default) | "oneOff" onResult: async (event) => { if (event.ok && event.eventType === "recurring.charged") { // persist event.customerId, event.recurringItemId, event.paymentId } + if (event.ok && event.eventType === "payment.succeeded") { + // one-off charge succeeded — persist event.paymentId, event.documentId + } }, }); ``` +| `mode` | Endpoint | Required item fields | +| ------------------------- | --------------------------------- | ----------------------------------------------------------------- | +| `"recurring"` *(default)* | `POST /billing/recurring/charge/` | `name`, `description`, `unitPrice`, `currency`, `durationMonths` | +| `"oneOff"` | `POST /billing/payments/charge/` | `name`, `description`, `unitPrice`, `currency` | + +The same `` and `SingleUseToken` work for both — only the route's `mode` changes. + What the handler does: | Step | Behaviour | | --------- | -------------------------------------------------------------------------------------------------------- | | Validate | Checks the JSON body shape (`singleUseToken`, `customer`, `item`). | -| Build | Calls `buildRecurringChargePayload` from `sumit-api`. | -| Send | `POST`s to `https://api.sumit.co.il/billing/recurring/charge/`. | -| Normalize | Calls `normalizeRecurringChargeResponse`. | +| Build | Calls `buildRecurringChargePayload` or `buildOneOffChargePayload` (per `mode`) from `sumit-api`. | +| Send | `POST`s to `/billing/recurring/charge/` or `/billing/payments/charge/` (per `mode`). | +| Normalize | Calls `normalizeChargeResponse`. | | Respond | `200` success, `402` declined, `400` bad input, `502` upstream failure — sensitive fields **redacted**. | --- diff --git a/src/next/createChargeRoute.test.ts b/src/next/createChargeRoute.test.ts index 095358b..265619a 100644 --- a/src/next/createChargeRoute.test.ts +++ b/src/next/createChargeRoute.test.ts @@ -137,4 +137,56 @@ describe("createSumitChargeRoute", () => { expect(response.status).toBe(502); expect(onError).toHaveBeenCalledOnce(); }); + + describe("mode: oneOff", () => { + const oneOffBody = { + singleUseToken: "tok_one_off", + customer: validBody.customer, + item: { name: "Setup fee", description: "One-time", unitPrice: 49, currency: "USD" as const }, + }; + + it("targets /billing/payments/charge/ and sends a payload without Duration_Months", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ Payment: { ID: 111, ValidPayment: true, Status: "000" }, CustomerID: "1", DocumentID: "9" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const handler = createSumitChargeRoute({ companyId: 7, apiKey: "k", mode: "oneOff", fetch: fetchMock as unknown as typeof fetch }); + const response = await handler(jsonRequest(oneOffBody)); + + expect(response.status).toBe(200); + const json = (await response.json()) as Record; + expect(json.eventType).toBe("payment.succeeded"); + expect(json.ok).toBe(true); + expect(json.recurringItemId).toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.sumit.co.il/billing/payments/charge/"); + const sentBody = JSON.parse(init.body as string) as { Items: Array> }; + expect(sentBody.Items[0]).not.toHaveProperty("Duration_Months"); + expect(sentBody.Items[0]).not.toHaveProperty("Recurrence"); + expect((sentBody.Items[0].Item as Record)).not.toHaveProperty("Duration_Months"); + }); + + it("does not require durationMonths in one-off mode", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ Payment: { ValidPayment: true, Status: "000" }, CustomerID: "1" }), { status: 200 }), + ); + const handler = createSumitChargeRoute({ companyId: 7, apiKey: "k", mode: "oneOff", fetch: fetchMock as unknown as typeof fetch }); + const response = await handler(jsonRequest(oneOffBody)); + expect(response.status).toBe(200); + }); + + it("rejects recurring requests missing durationMonths with a 400", async () => { + const fetchMock = vi.fn(); + const handler = createSumitChargeRoute({ companyId: 7, apiKey: "k", fetch: fetchMock as unknown as typeof fetch }); + const { durationMonths: _drop, ...itemWithoutDuration } = validBody.item; + const response = await handler(jsonRequest({ ...validBody, item: itemWithoutDuration })); + expect(response.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + const json = (await response.json()) as Record; + expect(json.error).toContain("item.durationMonths"); + }); + }); }); diff --git a/src/next/createChargeRoute.ts b/src/next/createChargeRoute.ts index 87d9a7a..0410535 100644 --- a/src/next/createChargeRoute.ts +++ b/src/next/createChargeRoute.ts @@ -1,16 +1,23 @@ import { + buildOneOffChargePayload, buildRecurringChargePayload, - normalizeRecurringChargeResponse, + normalizeChargeResponse, redactSumitPayload, } from "sumit-api"; import type { + BuildOneOffChargePayloadParams, BuildRecurringChargePayloadParams, NormalizedSumitEvent, SumitCurrency, } from "sumit-api"; const DEFAULT_BASE_URL = "https://api.sumit.co.il"; -const DEFAULT_PATH = "/billing/recurring/charge/"; +const DEFAULT_PATHS = { + recurring: "/billing/recurring/charge/", + oneOff: "/billing/payments/charge/", +} as const; + +export type SumitChargeMode = "recurring" | "oneOff"; export interface SumitChargeRequestBody { singleUseToken: string; @@ -24,8 +31,10 @@ export interface SumitChargeRequestBody { description: string; unitPrice: number; currency: SumitCurrency; - durationMonths: number; + /** Required for `mode: "recurring"`. Ignored in one-off mode. */ + durationMonths?: number; quantity?: number; + /** Recurring-only. Ignored in one-off mode. */ recurrence?: number; }; vatIncluded?: boolean; @@ -36,6 +45,8 @@ export interface SumitChargeRequestBody { export interface SumitChargeRouteConfig { companyId: number; apiKey: string; + /** Defaults to `"recurring"` for back-compat. Set to `"oneOff"` for `/billing/payments/charge/`. */ + mode?: SumitChargeMode; baseUrl?: string; path?: string; fetch?: typeof fetch; @@ -47,8 +58,9 @@ export interface SumitChargeRouteConfig { export type SumitChargeRouteHandler = (request: Request) => Promise; export function createSumitChargeRoute(config: SumitChargeRouteConfig): SumitChargeRouteHandler { + const mode: SumitChargeMode = config.mode ?? "recurring"; const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); - const path = config.path ?? DEFAULT_PATH; + const path = config.path ?? DEFAULT_PATHS[mode]; const upstreamFetch = config.fetch ?? fetch; return async function POST(request: Request): Promise { @@ -65,22 +77,12 @@ export function createSumitChargeRoute(config: SumitChargeRouteConfig): SumitCha return jsonResponse({ ok: false, error: "Missing required fields: singleUseToken, customer, item" }, 400); } - const validationError = validateChargeRequestBody(parsed); + const validationError = validateChargeRequestBody(parsed, mode); if (validationError) { return jsonResponse({ ok: false, error: validationError }, 400); } - const payloadParams: BuildRecurringChargePayloadParams = { - companyId: config.companyId, - apiKey: config.apiKey, - customer: parsed.customer, - singleUseToken: parsed.singleUseToken, - item: parsed.item, - vatIncluded: parsed.vatIncluded, - onlyDocument: parsed.onlyDocument, - authoriseOnly: parsed.authoriseOnly, - }; - const payload = buildRecurringChargePayload(payloadParams); + const payload = mode === "oneOff" ? buildOneOffChargePayload(toOneOffParams(parsed, config)) : buildRecurringChargePayload(toRecurringParams(parsed, config)); let upstreamJson: unknown; try { @@ -98,7 +100,7 @@ export function createSumitChargeRoute(config: SumitChargeRouteConfig): SumitCha return jsonResponse({ ok: false, error: "Upstream request to SUMIT failed" }, 502); } - const event = normalizeRecurringChargeResponse(upstreamJson); + const event = normalizeChargeResponse(upstreamJson); if (event.ok === null || event.eventType === "sumit.trigger.unmapped") { return jsonResponse({ ok: false, error: "SUMIT returned an unmapped charge response", event: redactSumitPayload(event) }, 502); } @@ -109,7 +111,34 @@ export function createSumitChargeRoute(config: SumitChargeRouteConfig): SumitCha }; } -function validateChargeRequestBody(body: SumitChargeRequestBody): string | null { +function toRecurringParams(body: SumitChargeRequestBody, config: SumitChargeRouteConfig): BuildRecurringChargePayloadParams { + return { + companyId: config.companyId, + apiKey: config.apiKey, + customer: body.customer, + singleUseToken: body.singleUseToken, + item: { ...body.item, durationMonths: body.item.durationMonths! }, + vatIncluded: body.vatIncluded, + onlyDocument: body.onlyDocument, + authoriseOnly: body.authoriseOnly, + }; +} + +function toOneOffParams(body: SumitChargeRequestBody, config: SumitChargeRouteConfig): BuildOneOffChargePayloadParams { + const { durationMonths: _durationMonths, recurrence: _recurrence, ...item } = body.item; + return { + companyId: config.companyId, + apiKey: config.apiKey, + customer: body.customer, + singleUseToken: body.singleUseToken, + item, + vatIncluded: body.vatIncluded, + onlyDocument: body.onlyDocument, + authoriseOnly: body.authoriseOnly, + }; +} + +function validateChargeRequestBody(body: SumitChargeRequestBody, mode: SumitChargeMode): string | null { if (!isNonEmptyString(body.singleUseToken)) return "singleUseToken must be a non-empty string"; if (!isNonEmptyString(body.customer.externalIdentifier)) return "customer.externalIdentifier must be a non-empty string"; if (!isNonEmptyString(body.customer.name)) return "customer.name must be a non-empty string"; @@ -117,7 +146,7 @@ function validateChargeRequestBody(body: SumitChargeRequestBody): string | null if (!isNonEmptyString(body.item.name)) return "item.name must be a non-empty string"; if (!isNonEmptyString(body.item.description)) return "item.description must be a non-empty string"; if (!isPositiveFiniteNumber(body.item.unitPrice)) return "item.unitPrice must be a positive number"; - if (!isPositiveFiniteNumber(body.item.durationMonths)) return "item.durationMonths must be a positive number"; + if (mode === "recurring" && !isPositiveFiniteNumber(body.item.durationMonths)) return "item.durationMonths must be a positive number"; if (!["ILS", "USD", "EUR", 0, 1, 2].includes(body.item.currency)) return "item.currency must be one of ILS, USD, EUR, 0, 1, 2"; if (body.item.quantity !== undefined && !isPositiveFiniteNumber(body.item.quantity)) return "item.quantity must be a positive number"; if (body.item.recurrence !== undefined && !isNonNegativeFiniteNumber(body.item.recurrence)) return "item.recurrence must be a non-negative number";