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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Companion package: [`sumit-react`](https://github.com/Digitizers/sumit-react).

Only these exports are stable:

- `buildOneOffChargePayload(params)` — assembles the `/billing/payments/charge/` request body.
- `buildRecurringChargePayload(params)` — assembles the `/billing/recurring/charge/` request body.
- `normalizeRecurringChargeResponse(response)` / `normalizeSumitIncomingPayload(payload)` — collapse the three SUMIT response shapes (JSON, urlencoded, `json=…` envelope) into a single `NormalizedSumitEvent`.
- `normalizeChargeResponse(response)` (alias: `normalizeRecurringChargeResponse`) / `normalizeSumitIncomingPayload(payload)` — collapse the three SUMIT response shapes (JSON, urlencoded, `json=…` envelope) into a single `NormalizedSumitEvent`. The same logic handles one-off and recurring responses; `recurring.charged` is surfaced only when `RecurringCustomerItemIDs[*]` is present.
- `redactSumitPayload(payload)` — recursive redactor for logs.
- `currencyToSumitCode` / `currencyFromSumitCode`.

Expand Down
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Companion package: [`sumit-react`](https://github.com/Digitizers/sumit-react)

- [Why this package](#why-this-package)
- [Install](#install)
- [Build a one-off charge payload](#build-a-one-off-charge-payload)
- [Build a recurring-charge payload](#build-a-recurring-charge-payload)
- [Normalize a charge response](#normalize-a-charge-response)
- [Normalize a SUMIT trigger / webhook payload](#normalize-a-sumit-trigger--webhook-payload)
Expand Down Expand Up @@ -51,6 +52,33 @@ The package has **no runtime dependencies**.

---

## Build a one-off charge payload

```ts
import { buildOneOffChargePayload } from "sumit-api";

const payload = buildOneOffChargePayload({
companyId: 123,
apiKey: process.env.SUMIT_API_KEY!,
customer: {
externalIdentifier: "org_123",
name: "Acme Ltd",
emailAddress: "billing@example.com",
},
singleUseToken: "[single-use-token-from-client]",
item: {
name: "Setup fee",
description: "One-time onboarding charge",
unitPrice: 49,
currency: "USD",
},
});
```

`POST` this body to `https://api.sumit.co.il/billing/payments/charge/`. The response uses the same shape recurring charges return, so [`normalizeChargeResponse`](#normalize-a-charge-response) handles both — a one-off success surfaces as `eventType: "payment.succeeded"` (no `recurringItemId`).

---

## Build a recurring-charge payload

```ts
Expand Down Expand Up @@ -79,17 +107,23 @@ const payload = buildRecurringChargePayload({

## Normalize a charge response

`normalizeChargeResponse` handles both one-off and recurring response shapes — a `recurring.charged` event is surfaced only when SUMIT returns a `RecurringCustomerItemIDs[*]`. (`normalizeRecurringChargeResponse` remains exported as an alias.)

```ts
import { normalizeRecurringChargeResponse } from "sumit-api";
import { normalizeChargeResponse } from "sumit-api";

const event = normalizeRecurringChargeResponse(sumitResponse);
const event = normalizeChargeResponse(sumitResponse);

if (event.ok && event.eventType === "recurring.charged") {
// Save event.customerId, event.recurringItemId, event.paymentId, event.documentId, ...
}

if (event.ok && event.eventType === "payment.succeeded") {
// One-off charge succeeded — store event.paymentId and event.documentId.
}

if (event.ok === false) {
// Don't activate the subscription. Store event.diagnostic safely.
// Don't activate the subscription / mark the order paid. Store event.diagnostic safely.
}
```

Expand Down
29 changes: 28 additions & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ SUMIT endpoints often return an outer wrapper:

The outer `Status` is the framework-level response status, not the payment status code. Payment approval codes live on `Data.Payment.Status` or `Payment.Status`. Trigger payloads can flatten `Data` to the top level; the normalizer checks both shapes.

## `POST /billing/payments/charge/`

One-off charge against a `SingleUseToken`. Built by `buildOneOffChargePayload`:

```ts
{
Credentials: { CompanyID, APIKey },
Customer: {
ExternalIdentifier: string,
SearchMode: 2,
Name: string,
EmailAddress: string,
},
SingleUseToken: string,
Items: [{
Item: { Name, Description },
Quantity: number,
UnitPrice: number,
Currency: 0 | 1 | 2,
}],
VATIncluded: boolean,
OnlyDocument: boolean,
AuthoriseOnly?: true,
}
```

Response shape matches `/billing/recurring/charge/` (same `Payment.*` envelope). Pass it to `normalizeChargeResponse` — a one-off success surfaces as `eventType: "payment.succeeded"` with no `recurringItemId`.

## `POST /billing/recurring/charge/`

Charges a customer and creates/updates a recurring item.
Expand Down Expand Up @@ -94,7 +122,6 @@ Successful payloads observed in smoke tests include `Payment.ValidPayment === tr
| `POST /billing/recurring/cancel/` | Cancel a recurring item. |
| `POST /billing/recurring/update/` | Update a recurring item. |
| `POST /billing/recurring/listforcustomer/` | List customer recurring items. |
| `POST /billing/payments/charge/` | One-off charge. |
| `POST /billing/payments/list/` | List historical payments. |
| `POST /billing/payments/get/` | Fetch one payment. |
| `POST /billing/payments/beginredirect/` | Start hosted/redirect checkout. |
Expand Down
61 changes: 61 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildOneOffChargePayload,
buildRecurringChargePayload,
normalizeChargeResponse,
normalizeRecurringChargeResponse,
normalizeSumitIncomingPayload,
redactSumitPayload,
Expand Down Expand Up @@ -55,6 +57,65 @@ describe("@deepclaw/sumit", () => {
});
});

it("builds the SUMIT one-off charge payload without Duration_Months/Recurrence", () => {
const payload = buildOneOffChargePayload({
companyId: 123,
apiKey: "api-key",
customer: {
externalIdentifier: "org-1",
name: "Acme",
emailAddress: "billing@example.invalid",
},
singleUseToken: "single-use-token",
item: {
name: "Pro Plan (one-off)",
description: "One-time charge",
quantity: 2,
unitPrice: 19,
currency: "ILS",
},
vatIncluded: false,
});

expect(payload).toEqual({
Credentials: { CompanyID: 123, APIKey: "api-key" },
Customer: {
ExternalIdentifier: "org-1",
SearchMode: 2,
Name: "Acme",
EmailAddress: "billing@example.invalid",
},
SingleUseToken: "single-use-token",
Items: [
{
Item: { Name: "Pro Plan (one-off)", Description: "One-time charge" },
Quantity: 2,
UnitPrice: 19,
Currency: 0,
},
],
VATIncluded: false,
OnlyDocument: false,
});
});

it("exposes normalizeChargeResponse as the canonical normalizer (alias for normalizeRecurringChargeResponse)", () => {
expect(normalizeChargeResponse).toBe(normalizeRecurringChargeResponse);
});

it("normalizes a one-off success response (no RecurringCustomerItemIDs) as payment.succeeded", () => {
const result = normalizeChargeResponse({
Payment: { ID: 111, CustomerID: 222, ValidPayment: true, Status: "000", Amount: 19, Currency: 0 },
DocumentID: 333,
});

expect(result.ok).toBe(true);
expect(result.eventType).toBe("payment.succeeded");
expect(result.recurringItemId).toBeUndefined();
expect(result.paymentId).toBe("111");
expect(result.documentId).toBe("333");
});

it("normalizes the successful SUMIT recurring charge response observed in production smoke", () => {
const result = normalizeRecurringChargeResponse({
Payment: {
Expand Down
109 changes: 82 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface SumitDiagnostic {
technicalErrorDetails?: string;
}

export interface BuildRecurringChargePayloadParams {
interface BaseChargeParams {
companyId: number;
apiKey: string;
customer: {
Expand All @@ -25,21 +25,33 @@ export interface BuildRecurringChargePayloadParams {
emailAddress: string;
};
singleUseToken: string;
item: {
name: string;
description: string;
quantity?: number;
unitPrice: number;
currency: SumitCurrency;
durationMonths: number;
recurrence?: number;
};
vatIncluded?: boolean;
onlyDocument?: boolean;
authoriseOnly?: boolean;
}

export interface SumitRecurringChargePayload {
export interface OneOffChargeItem {
name: string;
description: string;
quantity?: number;
unitPrice: number;
currency: SumitCurrency;
}

export interface RecurringChargeItem extends OneOffChargeItem {
durationMonths: number;
recurrence?: number;
}

export interface BuildOneOffChargePayloadParams extends BaseChargeParams {
item: OneOffChargeItem;
}

export interface BuildRecurringChargePayloadParams extends BaseChargeParams {
item: RecurringChargeItem;
}

interface BaseChargePayload {
Credentials: {
CompanyID: number;
APIKey: string;
Expand All @@ -51,6 +63,24 @@ export interface SumitRecurringChargePayload {
EmailAddress: string;
};
SingleUseToken: string;
VATIncluded: boolean;
OnlyDocument: boolean;
AuthoriseOnly?: true;
}

export interface SumitOneOffChargePayload extends BaseChargePayload {
Items: Array<{
Item: {
Name: string;
Description: string;
};
Quantity: number;
UnitPrice: number;
Currency: 0 | 1 | 2;
}>;
}

export interface SumitRecurringChargePayload extends BaseChargePayload {
Items: Array<{
Item: {
Name: string;
Expand All @@ -63,9 +93,6 @@ export interface SumitRecurringChargePayload {
Duration_Months: number;
Recurrence: number;
}>;
VATIncluded: boolean;
OnlyDocument: boolean;
AuthoriseOnly?: true;
}

export interface NormalizedSumitEvent {
Expand Down Expand Up @@ -104,19 +131,26 @@ export function currencyFromSumitCode(currency: unknown): "ILS" | "USD" | "EUR"
return String(currency);
}

export function buildOneOffChargePayload(params: BuildOneOffChargePayloadParams): SumitOneOffChargePayload {
return {
...baseChargeEnvelope(params),
Items: [
{
Item: {
Name: params.item.name,
Description: params.item.description,
},
Quantity: params.item.quantity ?? 1,
UnitPrice: params.item.unitPrice,
Currency: currencyToSumitCode(params.item.currency),
},
],
};
}

export function buildRecurringChargePayload(params: BuildRecurringChargePayloadParams): SumitRecurringChargePayload {
return {
Credentials: {
CompanyID: params.companyId,
APIKey: params.apiKey,
},
Customer: {
ExternalIdentifier: params.customer.externalIdentifier,
SearchMode: 2,
Name: params.customer.name,
EmailAddress: params.customer.emailAddress,
},
SingleUseToken: params.singleUseToken,
...baseChargeEnvelope(params),
Items: [
{
Item: {
Expand All @@ -131,6 +165,22 @@ export function buildRecurringChargePayload(params: BuildRecurringChargePayloadP
Recurrence: params.item.recurrence ?? 0,
},
],
};
}

function baseChargeEnvelope(params: BaseChargeParams): BaseChargePayload {
return {
Credentials: {
CompanyID: params.companyId,
APIKey: params.apiKey,
},
Customer: {
ExternalIdentifier: params.customer.externalIdentifier,
SearchMode: 2,
Name: params.customer.name,
EmailAddress: params.customer.emailAddress,
},
SingleUseToken: params.singleUseToken,
VATIncluded: params.vatIncluded ?? true,
OnlyDocument: params.onlyDocument ?? false,
...(params.authoriseOnly ? { AuthoriseOnly: true as const } : {}),
Expand All @@ -139,10 +189,15 @@ export function buildRecurringChargePayload(params: BuildRecurringChargePayloadP

export function normalizeSumitIncomingPayload(payload: unknown): NormalizedSumitEvent {
const objectPayload = unwrapSumitJsonEnvelope(payload instanceof URLSearchParams ? formToNestedObject(payload) : payload);
return normalizeRecurringChargeResponse(objectPayload);
return normalizeChargeResponse(objectPayload);
}

export function normalizeRecurringChargeResponse(response: unknown): NormalizedSumitEvent {
// Same logic for one-off and recurring responses — the response shape is
// shared, and the normalizer surfaces `recurring.charged` only when a
// `RecurringCustomerItemIDs[*]` is present.
export const normalizeRecurringChargeResponse = normalizeChargeResponse;

export function normalizeChargeResponse(response: unknown): NormalizedSumitEvent {
if (!isRecord(response)) {
return unmappedDiagnostic(null);
}
Expand Down
Loading