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

- `buildOneOffChargePayload(params)` — builder for `POST /billing/payments/charge/`. Items omit the recurring-only `Duration_Months` and `Recurrence` fields.
- `normalizeChargeResponse(response)` — exported as the canonical normalizer for both one-off and recurring charge responses. The same logic surfaces `eventType: "recurring.charged"` only when SUMIT returns a `RecurringCustomerItemIDs[*]`.
- Type exports: `BuildOneOffChargePayloadParams`, `SumitOneOffChargePayload`, `OneOffChargeItem`, `RecurringChargeItem`.

### Changed

- README and API reference document the new one-off endpoint and the dual-mode normalizer.

### Notes

- `normalizeRecurringChargeResponse` remains exported as an alias for `normalizeChargeResponse` — no breaking change.

## [0.1.0] - 2026-05-01

### Added

- Initial release.
- `buildRecurringChargePayload`, `normalizeRecurringChargeResponse`, `normalizeSumitIncomingPayload`, `redactSumitPayload`, `currencyToSumitCode`, `currencyFromSumitCode`.
- Two-layer redaction (key-based `SENSITIVE_KEY_PATTERN` + text-based `redactSensitiveText`).
- Prototype-pollution guard in form-encoded payload parsing.
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sumit-api",
"version": "0.1.0",
"version": "0.2.0",
"description": "TypeScript helpers for SUMIT (formerly OfficeGuy) recurring charges and trigger webhooks. Includes redaction for upstream Upay clearer error codes.",
"license": "MIT",
"type": "module",
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
Loading
Loading