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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Changelog

All notable changes to this package are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-05-02

### Added

- `createSumitChargeRoute` accepts `mode: "recurring" | "oneOff"` (default `"recurring"`). One-off mode targets `POST /billing/payments/charge/`, calls `buildOneOffChargePayload` from `sumit-api`, and relaxes the `durationMonths` validation requirement. The same `<SumitCheckout />` and `SingleUseToken` work for both modes.
- Type export: `SumitChargeMode`.

### Changed

- Internal switch from `normalizeRecurringChargeResponse` to its new alias `normalizeChargeResponse`. Behaviour identical.
- `peerDependencies.sumit-api` bumped to `>=0.2.0` (one-off mode imports the new `buildOneOffChargePayload`).

### Notes

- Existing recurring callers see no change.

## [0.1.1] - 2026-05-01

### Changed

- Hardened `verifySumitSharedSecret` (constant-time and length-independent comparison via SHA-256).
- `<SumitCheckout />` uses a synchronous `useRef` guard against double-submit races.
- Misc test, route, and configuration improvements; CI now builds `sumit-api` as a sibling.

## [0.1.0] - 2026-05-01

### Added

- Initial release.
- `<SumitCheckout />` React component, `useSumitCheckout` hook, `loadSumitPayments` / `createSingleUseToken` client utilities.
- `createSumitChargeRoute`, `createSumitWebhookRoute`, `verifySumitSharedSecret` Next.js / Web Standard route helpers.
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sumit-react",
"version": "0.1.1",
"version": "0.2.0",
"description": "React components and Next.js route helpers for SUMIT (formerly OfficeGuy) payments.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -48,7 +48,7 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"sumit-api": ">=0.1.0",
"sumit-api": ">=0.2.0",
"react": ">=18.0.0"
},
"devDependencies": {
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,
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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Export SumitChargeMode from the public next API

SumitChargeMode is declared as an exported type here, but the package’s public ./next entrypoint only exposes types re-exported from src/next/index.ts. Because that barrel was not updated in this change, consumers cannot actually import SumitChargeMode from sumit-react/next, so the new API advertised in this release is not reachable through the supported import path.

Useful? React with 👍 / 👎.


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];
Comment on lines +61 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate mode before using it to select default endpoint

mode is taken directly from runtime config and used to index DEFAULT_PATHS without a runtime guard. In JavaScript consumers (or TS callers using as any), a typo like "oneoff" makes DEFAULT_PATHS[mode] undefined, so requests are sent to https://api.sumit.co.ilundefined and fail later as opaque upstream errors. Adding an explicit mode check would fail fast with a clear configuration error.

Useful? React with 👍 / 👎.

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