diff --git a/CLAUDE.md b/CLAUDE.md index e48398c..5756388 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index 669bf95..896dc4f 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. } ``` diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index d501825..f138962 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. @@ -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. | diff --git a/src/index.test.ts b/src/index.test.ts index acad465..c161563 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + buildOneOffChargePayload, buildRecurringChargePayload, + normalizeChargeResponse, normalizeRecurringChargeResponse, normalizeSumitIncomingPayload, redactSumitPayload, @@ -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: { diff --git a/src/index.ts b/src/index.ts index 232cee6..6fea1ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export interface SumitDiagnostic { technicalErrorDetails?: string; } -export interface BuildRecurringChargePayloadParams { +interface BaseChargeParams { companyId: number; apiKey: string; customer: { @@ -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; @@ -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; @@ -63,9 +93,6 @@ export interface SumitRecurringChargePayload { Duration_Months: number; Recurrence: number; }>; - VATIncluded: boolean; - OnlyDocument: boolean; - AuthoriseOnly?: true; } export interface NormalizedSumitEvent { @@ -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: { @@ -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 } : {}), @@ -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); }