Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<SumitCheckout />` 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**. |

---
Expand Down
52 changes: 52 additions & 0 deletions src/next/createChargeRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<Record<string, unknown>> };
expect(sentBody.Items[0]).not.toHaveProperty("Duration_Months");
expect(sentBody.Items[0]).not.toHaveProperty("Recurrence");
expect((sentBody.Items[0].Item as Record<string, unknown>)).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<string, unknown>;
expect(json.error).toContain("item.durationMonths");
});
});
});
67 changes: 48 additions & 19 deletions src/next/createChargeRoute.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import {
buildOneOffChargePayload,
buildRecurringChargePayload,
normalizeRecurringChargeResponse,
normalizeChargeResponse,
Comment on lines +2 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump required sumit-api version for new imports

This change adds static imports for buildOneOffChargePayload and normalizeChargeResponse, but package.json still allows sumit-api >=0.1.0; any install that resolves an older compatible version will fail module loading before the handler runs (including recurring-only usage) because ESM imports are resolved eagerly. Please raise the peer/dev dependency minimum to the first sumit-api release that exports these symbols so consumers do not hit runtime/boot-time import errors.

Useful? React with 👍 / 👎.

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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -47,8 +58,9 @@ export interface SumitChargeRouteConfig {
export type SumitChargeRouteHandler = (request: Request) => Promise<Response>;

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<Response> {
Expand All @@ -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 {
Expand All @@ -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);
}
Expand All @@ -109,15 +111,42 @@ 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";
if (!isNonEmptyString(body.customer.emailAddress)) return "customer.emailAddress must be a non-empty string";
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";
Expand Down
Loading