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";