Initial Commit
This commit is contained in:
104
packages/money/payment/payment-provider-square/README.md
Normal file
104
packages/money/payment/payment-provider-square/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @suaveplan/payment-provider-square - Universal Square Payments API adapter for @suaveplan/payment. Ships SquareProvider (implements PaymentProvider with literal name "square"), SquareConfig (extends ApiKeyConfig with locationId, sandbox, apiVersion, randomId), and the Square-specific payment/refund mappers. Built on the @suaveplan/payment-providers foundation (httpRequest, composeAbortSignal, PAYMENT_PROVIDER_CODES, PaymentProviderError).
|
||||
|
||||
Universal Square Payments API adapter for `@suaveplan/payment`.
|
||||
Ships the `SquareProvider` class (implements the structural
|
||||
`PaymentProvider` contract with literal `name: "square"`), the
|
||||
`SquareConfig` interface (extends `ApiKeyConfig` from
|
||||
`@suaveplan/payment-providers` with the Square-specific `locationId`,
|
||||
`sandbox`, `apiVersion`, and `randomId` fields), and the
|
||||
Square-specific payment / refund mappers (`mapPayment`, `mapRefund`,
|
||||
`mapPaymentStatus`, `mapRefundStatus`).
|
||||
|
||||
Built on the `@suaveplan/payment-providers` foundation — every
|
||||
outbound HTTP call goes through `httpRequest`, which handles JSON
|
||||
serialisation, the per-call timeout signal, external `AbortSignal`
|
||||
composition, and typed `PaymentProviderError` raising.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [payment](./src/mapping/payment.md) — Translates between Square's `Payment` envelope (as returned by `POST /v2/payments`, `POST /v2/payments/{id}/complete`,…
|
||||
- [refund](./src/mapping/refund.md) — Translates Square's `Refund` envelope (as returned by `POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. T…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [types](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
- [config](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-square @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new SquareProvider({
|
||||
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
|
||||
locationId: process.env.SQUARE_LOCATION_ID ?? "",
|
||||
})
|
||||
);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("square") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`SquareProvider`** — full Square Payments API lifecycle
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) with literal `name: "square"`.
|
||||
- **`SquareConfig`** — extends `ApiKeyConfig`; adds required
|
||||
`locationId` plus optional `sandbox`, `apiVersion`, `randomId`.
|
||||
- **Sandbox toggle** — `sandbox: true` flips the default base URL to
|
||||
`https://connect.squareupsandbox.com/v2`. Explicit `baseUrl`
|
||||
always wins.
|
||||
- **Pinned `Square-Version`** — defaults to `2024-01-18`; override via
|
||||
`apiVersion` to opt into a newer API surface.
|
||||
- **Injectable `randomId`** — fills missing `idempotency_key` fields
|
||||
on `CreatePayment` + `RefundPayment` calls. Tests pass a
|
||||
deterministic counter; production defaults to `crypto.randomUUID()`.
|
||||
- **Universal** — no `node:`, `bun:`, `fs`, `path`, `crypto`
|
||||
imports; runs on Node, Bun, browsers, and Web Workers.
|
||||
- **`PaymentProviderError`-only** — every failure surfaces as a
|
||||
typed `PaymentProviderError` via the foundation's `httpRequest`.
|
||||
- **100% test coverage** (Istanbul) + sibling `.md` per
|
||||
implementation file + 5 subpath exports.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/square/square.md`](./src/square/square.md) — `SquareProvider`
|
||||
class and lifecycle methods.
|
||||
- [`src/types/config.md`](./src/types/config.md) — `SquareConfig`,
|
||||
`SQUARE_DEFAULT_BASE_URL`, `SQUARE_SANDBOX_BASE_URL`,
|
||||
`SQUARE_DEFAULT_API_VERSION`.
|
||||
- [`src/mapping/payment.md`](./src/mapping/payment.md) — `mapPayment`,
|
||||
`mapPaymentStatus`, `SquarePaymentDto`, `SquareMoney`, `SquareCard`.
|
||||
- [`src/mapping/refund.md`](./src/mapping/refund.md) — `mapRefund`,
|
||||
`mapRefundStatus`, `SquareRefundDto`.
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment-providers` — `ProviderRegistry`,
|
||||
`definePaymentProvider`, `httpRequest`, `composeAbortSignal`,
|
||||
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`,
|
||||
`ApiKeyConfig`/`OAuthConfig`/`MerchantKeyConfig`,
|
||||
`FetchLike`/`RandomIdFn`/`NowFn`/`Base64EncodeFn`.
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface, the canonical `PaymentIntent` /
|
||||
`Refund` / `PaymentStatus` / `RefundStatus` shapes.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
69
packages/money/payment/payment-provider-square/package.json
Normal file
69
packages/money/payment/payment-provider-square/package.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "@suaveplan/payment-provider-square",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal Square Payments API adapter for @suaveplan/payment. Ships SquareProvider (implements PaymentProvider with literal name \"square\"), SquareConfig (extends ApiKeyConfig with locationId, sandbox, apiVersion, randomId), and the Square-specific payment/refund mappers. Built on the @suaveplan/payment-providers foundation (httpRequest, composeAbortSignal, PAYMENT_PROVIDER_CODES, PaymentProviderError).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./square/square": {
|
||||
"import": { "types": "./dist/square/square.d.ts", "default": "./dist/square/square.js" },
|
||||
"require": { "types": "./dist/square/square.d.ts", "default": "./dist/square/square.js" }
|
||||
},
|
||||
"./types/config": {
|
||||
"import": { "types": "./dist/types/config.d.ts", "default": "./dist/types/config.js" },
|
||||
"require": { "types": "./dist/types/config.d.ts", "default": "./dist/types/config.js" }
|
||||
},
|
||||
"./mapping/payment": {
|
||||
"import": { "types": "./dist/mapping/payment.d.ts", "default": "./dist/mapping/payment.js" },
|
||||
"require": { "types": "./dist/mapping/payment.d.ts", "default": "./dist/mapping/payment.js" }
|
||||
},
|
||||
"./mapping/refund": {
|
||||
"import": { "types": "./dist/mapping/refund.d.ts", "default": "./dist/mapping/refund.js" },
|
||||
"require": { "types": "./dist/mapping/refund.d.ts", "default": "./dist/mapping/refund.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/payment": "workspace:^",
|
||||
"@suaveplan/payment-providers": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
19
packages/money/payment/payment-provider-square/src/index.ts
Normal file
19
packages/money/payment/payment-provider-square/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
type SquareCard,
|
||||
type SquareMoney,
|
||||
type SquarePaymentDto,
|
||||
} from "./mapping/payment.js";
|
||||
export {
|
||||
mapRefund,
|
||||
mapRefundStatus,
|
||||
type SquareRefundDto,
|
||||
} from "./mapping/refund.js";
|
||||
export { SquareProvider } from "./square/square.js";
|
||||
export {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_DEFAULT_BASE_URL,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
type SquareConfig,
|
||||
} from "./types/config.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
type SquareCard,
|
||||
type SquareMoney,
|
||||
type SquarePaymentDto,
|
||||
} from "./payment.js";
|
||||
export {
|
||||
mapRefund,
|
||||
mapRefundStatus,
|
||||
type SquareRefundDto,
|
||||
} from "./refund.js";
|
||||
@@ -0,0 +1,126 @@
|
||||
# Square payment mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates between Square's `Payment` envelope (as returned by
|
||||
`POST /v2/payments`, `POST /v2/payments/{id}/complete`,
|
||||
`POST /v2/payments/{id}/cancel`, and `GET /v2/payments/{id}`) and
|
||||
`@suaveplan/payment`'s `PaymentIntent` shape. Square's status values
|
||||
are upper-snake-case (`COMPLETED`, `APPROVED`, ...) while uses
|
||||
the lower-case `PaymentStatus` union; the mapping pins this contract
|
||||
in one place so the provider class stays focused on transport.
|
||||
|
||||
The mapper also captures the two implicit branches Square encodes via
|
||||
optional fields rather than dedicated states: `approved_money`
|
||||
present ↔ manual capture, `card_details` present ↔ card-shaped
|
||||
`PaymentMethod`.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapPaymentStatus(squareStatus)` — exhaustive five-status map with a
|
||||
defensive `"failed"` fallback for forward-rolled API versions.
|
||||
- `mapPayment(p)` — full envelope-to-`PaymentIntent` shape including
|
||||
`capturedAmount` derivation (`p.total_money` on `COMPLETED`, `0n`
|
||||
otherwise), `captureMethod` derivation (`approved_money` ↔
|
||||
`"manual"`), and optional `PaymentMethod` derivation from
|
||||
`card_details`.
|
||||
- `SquareCard` / `SquareMoney` / `SquarePaymentDto` — typed views of
|
||||
the Square API envelope that downstream code can rely on instead of
|
||||
threading `unknown` through call sites.
|
||||
- Card-brand normalisation — Square returns brands upper-case; the
|
||||
mapper lower-cases and validates against the `CardBrand`
|
||||
union, falling back to `"unknown"` for non-canonical brands
|
||||
(Square gift cards, JCB sub-brands, etc.).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
mapPaymentStatus("COMPLETED"); // "captured"
|
||||
|
||||
const intent = mapPayment({
|
||||
id: "sq_pay_123",
|
||||
status: "COMPLETED",
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
console.log(intent.status); // "captured"
|
||||
console.log(intent.capturedAmount.amount); // 5000n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Map an authorized-but-not-captured payment with card metadata:
|
||||
|
||||
```ts
|
||||
import { mapPayment } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const intent = mapPayment({
|
||||
id: "sq_pay_456",
|
||||
status: "APPROVED",
|
||||
amount_money: { amount: 9999, currency: "EUR" },
|
||||
total_money: { amount: 9999, currency: "EUR" },
|
||||
approved_money: { amount: 9999, currency: "EUR" },
|
||||
customer_id: "cust_42",
|
||||
note: "Pre-authorisation for hold",
|
||||
card_details: { card: { card_brand: "MASTERCARD", last_4: "4242" } },
|
||||
created_at: "2026-02-01T10:00:00Z",
|
||||
updated_at: "2026-02-01T10:00:00Z",
|
||||
});
|
||||
|
||||
console.log(intent.captureMethod); // "manual"
|
||||
console.log(intent.status); // "authorized"
|
||||
console.log(intent.capturedAmount.amount); // 0n
|
||||
console.log(intent.paymentMethod);
|
||||
// { id: "sq_card_sq_pay_456", type: "card", brand: "mastercard", last4: "4242" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapPaymentStatus(squareStatus: string): PaymentStatus`
|
||||
|
||||
Maps Square status → suaveplan status. Known mappings:
|
||||
|
||||
| Square | suaveplan |
|
||||
|---|---|
|
||||
| `APPROVED` | `"authorized"` |
|
||||
| `COMPLETED` | `"captured"` |
|
||||
| `CANCELED` | `"canceled"` |
|
||||
| `FAILED` | `"failed"` |
|
||||
| `PENDING` | `"pending"` |
|
||||
| _other_ | `"failed"` |
|
||||
|
||||
### `mapPayment(p: SquarePaymentDto): PaymentIntent`
|
||||
|
||||
Shape the envelope. `capturedAmount.amount` is
|
||||
`BigInt(p.total_money.amount)` when `p.status === "COMPLETED"`,
|
||||
otherwise `0n`. `captureMethod` is `"manual"` when `p.approved_money`
|
||||
is set. `paymentMethod` is set when `p.card_details` is present.
|
||||
`refundedAmount.amount` is `0n` (refund totals are tracked by
|
||||
`PaymentEngine`, not derived here). `refunds` is an empty array.
|
||||
|
||||
### Types
|
||||
|
||||
- `SquareMoney` — `{ amount: number; currency: string }`.
|
||||
- `SquareCard` — `{ card_brand: string; last_4: string }`.
|
||||
- `SquarePaymentDto` — the subset of Square's `Payment` envelope this
|
||||
package consumes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The status map is frozen at module init so accidental mutation
|
||||
throws in strict mode.
|
||||
- The card-brand normaliser is intentionally conservative: any brand
|
||||
Square introduces that does not match the `CardBrand` union
|
||||
falls back to `"unknown"` so downstream code never receives an
|
||||
invalid discriminant.
|
||||
- `paymentMethod.id` is synthesised as `sq_card_<payment-id>` because
|
||||
Square does not expose a stable card-fingerprint on the inline
|
||||
`card_details.card` object. Consumers needing a true stable card id
|
||||
should hydrate it from Square's Cards API and overlay it.
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
type SquarePaymentDto,
|
||||
} from "./payment.js";
|
||||
|
||||
function basePayment(
|
||||
overrides: Partial<SquarePaymentDto> = {}
|
||||
): SquarePaymentDto {
|
||||
return {
|
||||
id: "sq_pay_123",
|
||||
status: "COMPLETED",
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
customer_id: "cust_1",
|
||||
note: "Test payment",
|
||||
card_details: { card: { card_brand: "VISA", last_4: "1234" } },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapPaymentStatus", () => {
|
||||
it("maps every known Square status to the documented PaymentStatus", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapPaymentStatus("APPROVED")).toBe("authorized");
|
||||
expect(mapPaymentStatus("COMPLETED")).toBe("captured");
|
||||
expect(mapPaymentStatus("CANCELED")).toBe("canceled");
|
||||
expect(mapPaymentStatus("FAILED")).toBe("failed");
|
||||
expect(mapPaymentStatus("PENDING")).toBe("pending");
|
||||
});
|
||||
|
||||
it("falls back to failed for unknown Square status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapPaymentStatus("SOMETHING_NEW")).toBe("failed");
|
||||
expect(mapPaymentStatus("")).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPayment", () => {
|
||||
it("maps a COMPLETED payment with card details", { timeout: 5000 }, () => {
|
||||
const result = mapPayment(basePayment());
|
||||
expect(result.id).toBe("sq_pay_123");
|
||||
expect(result.status).toBe("captured");
|
||||
expect(result.amount).toEqual({
|
||||
amount: 5000n,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.capturedAmount).toEqual({
|
||||
amount: 5000n,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.refundedAmount).toEqual({
|
||||
amount: 0n,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.captureMethod).toBe("automatic");
|
||||
expect(result.customerId).toBe("cust_1");
|
||||
expect(result.description).toBe("Test payment");
|
||||
expect(result.paymentMethod).toEqual({
|
||||
id: "sq_card_sq_pay_123",
|
||||
type: "card",
|
||||
brand: "visa",
|
||||
last4: "1234",
|
||||
});
|
||||
expect(result.refunds).toEqual([]);
|
||||
});
|
||||
|
||||
it("flips captureMethod to manual when approved_money is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = mapPayment(
|
||||
basePayment({
|
||||
status: "APPROVED",
|
||||
approved_money: { amount: 5000, currency: "USD" },
|
||||
})
|
||||
);
|
||||
expect(result.captureMethod).toBe("manual");
|
||||
expect(result.status).toBe("authorized");
|
||||
expect(result.capturedAmount.amount).toBe(0n);
|
||||
});
|
||||
|
||||
it("sets paymentMethod to undefined when card_details is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = mapPayment(basePayment({ card_details: undefined }));
|
||||
expect(result.paymentMethod).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lower-cases the card brand and falls back to unknown for non-canonical brands", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mc = mapPayment(
|
||||
basePayment({
|
||||
card_details: {
|
||||
card: { card_brand: "MASTERCARD", last_4: "4444" },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mc.paymentMethod?.brand).toBe("mastercard");
|
||||
|
||||
const obscure = mapPayment(
|
||||
basePayment({
|
||||
card_details: {
|
||||
card: { card_brand: "SQUARE_GIFT", last_4: "0000" },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(obscure.paymentMethod?.brand).toBe("unknown");
|
||||
});
|
||||
|
||||
it("leaves capturedAmount at zero when status is not COMPLETED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = mapPayment(basePayment({ status: "PENDING" }));
|
||||
expect(result.status).toBe("pending");
|
||||
expect(result.capturedAmount.amount).toBe(0n);
|
||||
});
|
||||
|
||||
it("omits optional fields when Square envelope omits them", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = mapPayment(
|
||||
basePayment({
|
||||
customer_id: undefined,
|
||||
note: undefined,
|
||||
card_details: undefined,
|
||||
})
|
||||
);
|
||||
expect(result.customerId).toBeUndefined();
|
||||
expect(result.description).toBeUndefined();
|
||||
expect(result.paymentMethod).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
CardBrand,
|
||||
PaymentIntent,
|
||||
PaymentStatus,
|
||||
} from "@suaveplan/payment";
|
||||
|
||||
/**
|
||||
* Subset of Square's `Money` envelope. Square serialises every
|
||||
* money-typed field as `{ amount: number, currency: string }` where
|
||||
* `amount` is the smallest-unit integer (cents for USD, etc.).
|
||||
*/
|
||||
export interface SquareMoney {
|
||||
readonly amount: number;
|
||||
readonly currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of Square's `Card` envelope captured on `card_details.card`.
|
||||
* Square reports `card_brand` in upper case; the mapping function
|
||||
* lower-cases it to match `@suaveplan/payment`'s `CardBrand` union.
|
||||
*/
|
||||
export interface SquareCard {
|
||||
readonly card_brand: string;
|
||||
readonly last_4: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of Square's `Payment` envelope this package consumes. Square
|
||||
* documents additional fields (location_id, processing_fee, etc.)
|
||||
* that are not part of the genesis `PaymentIntent` shape.
|
||||
*/
|
||||
export interface SquarePaymentDto {
|
||||
readonly id: string;
|
||||
readonly status: string;
|
||||
readonly amount_money: SquareMoney;
|
||||
readonly total_money: SquareMoney;
|
||||
readonly approved_money?: SquareMoney;
|
||||
readonly customer_id?: string;
|
||||
readonly note?: string;
|
||||
readonly source_type?: string;
|
||||
readonly card_details?: { readonly card: SquareCard };
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Readonly<Record<string, PaymentStatus>> = Object.freeze({
|
||||
APPROVED: "authorized",
|
||||
COMPLETED: "captured",
|
||||
CANCELED: "canceled",
|
||||
FAILED: "failed",
|
||||
PENDING: "pending",
|
||||
});
|
||||
|
||||
/**
|
||||
* Map Square's payment status to the genesis {@link PaymentStatus}
|
||||
* union. Unknown statuses fall back to `"failed"` so a forward-rolled
|
||||
* API version cannot widen the union silently.
|
||||
*/
|
||||
export function mapPaymentStatus(squareStatus: string): PaymentStatus {
|
||||
return STATUS_MAP[squareStatus] ?? "failed";
|
||||
}
|
||||
|
||||
function brandFrom(card: SquareCard): CardBrand {
|
||||
const brand = card.card_brand.toLowerCase();
|
||||
const known: readonly CardBrand[] = [
|
||||
"visa",
|
||||
"mastercard",
|
||||
"amex",
|
||||
"discover",
|
||||
"diners",
|
||||
"jcb",
|
||||
"unionpay",
|
||||
];
|
||||
return (known as readonly string[]).includes(brand)
|
||||
? (brand as CardBrand)
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a Square `Payment` envelope into a genesis
|
||||
* {@link PaymentIntent}. Captures the `COMPLETED` → captured-amount
|
||||
* branch, the `approved_money` → manual-capture branch, and the
|
||||
* `card_details` → `PaymentMethod` branch.
|
||||
*/
|
||||
export function mapPayment(p: SquarePaymentDto): PaymentIntent {
|
||||
const currency = p.amount_money.currency;
|
||||
const capturedRaw =
|
||||
p.status === "COMPLETED" ? BigInt(p.total_money.amount) : 0n;
|
||||
|
||||
const base = {
|
||||
id: p.id,
|
||||
status: mapPaymentStatus(p.status),
|
||||
amount: { amount: BigInt(p.amount_money.amount), currency },
|
||||
capturedAmount: { amount: capturedRaw, currency },
|
||||
refundedAmount: { amount: 0n, currency },
|
||||
captureMethod: (p.approved_money ? "manual" : "automatic") as
|
||||
| "manual"
|
||||
| "automatic",
|
||||
refunds: [] as const,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
};
|
||||
const withCustomer =
|
||||
p.customer_id !== undefined
|
||||
? { ...base, customerId: p.customer_id }
|
||||
: base;
|
||||
const withDescription =
|
||||
p.note !== undefined
|
||||
? { ...withCustomer, description: p.note }
|
||||
: withCustomer;
|
||||
const intent: PaymentIntent = p.card_details
|
||||
? {
|
||||
...withDescription,
|
||||
paymentMethod: {
|
||||
id: `sq_card_${p.id}`,
|
||||
type: "card",
|
||||
brand: brandFrom(p.card_details.card),
|
||||
last4: p.card_details.card.last_4,
|
||||
},
|
||||
}
|
||||
: withDescription;
|
||||
return intent;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Square refund mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates Square's `Refund` envelope (as returned by
|
||||
`POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. The
|
||||
mapping is small but load-bearing: Square encodes refund lifecycle
|
||||
states as upper-snake-case strings while the `RefundStatus`
|
||||
union is lower-case, and Square's `reason` is a free-form string
|
||||
where the `RefundReason` union is a closed discriminator.
|
||||
|
||||
The reason mapping is intentionally narrow — Square never reports
|
||||
machine-readable reason codes, so any non-empty reason collapses to
|
||||
`"requested_by_customer"` and an absent / empty reason collapses to
|
||||
`"other"`. Consumers that need richer reason granularity must layer
|
||||
their own mapping on top.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapRefundStatus(squareStatus)` — exhaustive three-state map
|
||||
(`COMPLETED`, `PENDING`, everything-else) with `"failed"` covering
|
||||
Square's `REJECTED` and `FAILED` plus any forward-rolled values.
|
||||
- `mapRefund(r)` — full envelope-to-`Refund` shape including bigint
|
||||
amount coercion and reason defaulting.
|
||||
- `SquareRefundDto` — typed view of the Square refund envelope.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapRefund,
|
||||
mapRefundStatus,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
mapRefundStatus("COMPLETED"); // "succeeded"
|
||||
|
||||
const refund = mapRefund({
|
||||
id: "sq_ref_123",
|
||||
payment_id: "sq_pay_123",
|
||||
amount_money: { amount: 2000, currency: "USD" },
|
||||
status: "COMPLETED",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
reason: "Customer changed mind",
|
||||
});
|
||||
|
||||
console.log(refund.status); // "succeeded"
|
||||
console.log(refund.reason); // "requested_by_customer"
|
||||
console.log(refund.amount.amount); // 2000n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Show all three branches in turn — including the reason fallback and
|
||||
the `REJECTED → "failed"` collapse:
|
||||
|
||||
```ts
|
||||
import { mapRefund } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const base = {
|
||||
id: "sq_ref_a",
|
||||
payment_id: "sq_pay_a",
|
||||
amount_money: { amount: 500, currency: "GBP" },
|
||||
created_at: "2026-03-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const pending = mapRefund({ ...base, status: "PENDING" });
|
||||
console.log(pending.status); // "pending"
|
||||
console.log(pending.reason); // "other" (no reason supplied)
|
||||
|
||||
const rejected = mapRefund({ ...base, status: "REJECTED", reason: "" });
|
||||
console.log(rejected.status); // "failed"
|
||||
console.log(rejected.reason); // "other" (empty string)
|
||||
|
||||
const succeeded = mapRefund({
|
||||
...base,
|
||||
status: "COMPLETED",
|
||||
reason: "duplicate charge",
|
||||
});
|
||||
console.log(succeeded.status); // "succeeded"
|
||||
console.log(succeeded.reason); // "requested_by_customer"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapRefundStatus(squareRefundStatus: string): RefundStatus`
|
||||
|
||||
| Square | suaveplan |
|
||||
|---|---|
|
||||
| `COMPLETED` | `"succeeded"` |
|
||||
| `PENDING` | `"pending"` |
|
||||
| _other_ (`REJECTED`, `FAILED`, ...) | `"failed"` |
|
||||
|
||||
### `mapRefund(r: SquareRefundDto): Refund`
|
||||
|
||||
Produces a `Refund` whose `amount` mirrors `r.amount_money` (with
|
||||
the amount widened to `bigint`), whose `status` comes from
|
||||
`mapRefundStatus`, whose `reason` is `"requested_by_customer"` when
|
||||
`r.reason` is a non-empty string and `"other"` otherwise, and whose
|
||||
`createdAt` is `r.created_at` verbatim. `paymentIntentId` is set to
|
||||
`r.payment_id`.
|
||||
|
||||
### Types
|
||||
|
||||
- `SquareRefundDto` — the subset of Square's `Refund` envelope this
|
||||
package consumes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The reason fallback to `"other"` is deliberate: Square's free-form
|
||||
`reason` carries operational notes ("customer called", "fraud
|
||||
flagged by ops"), not the discriminated reasons suaveplan tracks.
|
||||
Treating any non-empty value as `"requested_by_customer"` matches
|
||||
the upstream package's behaviour verbatim.
|
||||
- `mapRefundStatus` is exported separately so callers consuming raw
|
||||
Square webhooks (where the refund envelope arrives outside an HTTP
|
||||
response) can reuse the same mapping logic without round-tripping
|
||||
through `mapRefund`.
|
||||
- `r.amount_money.amount` is coerced via `BigInt()`. Square uses
|
||||
smallest-unit integers, so the coercion is exact for every
|
||||
documented currency.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mapRefund, mapRefundStatus, type SquareRefundDto } from "./refund.js";
|
||||
|
||||
function baseRefund(overrides: Partial<SquareRefundDto> = {}): SquareRefundDto {
|
||||
return {
|
||||
id: "sq_ref_123",
|
||||
payment_id: "sq_pay_123",
|
||||
amount_money: { amount: 2000, currency: "USD" },
|
||||
status: "COMPLETED",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
reason: "Customer request",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapRefundStatus", () => {
|
||||
it("maps every known Square refund status", { timeout: 5000 }, () => {
|
||||
expect(mapRefundStatus("COMPLETED")).toBe("succeeded");
|
||||
expect(mapRefundStatus("PENDING")).toBe("pending");
|
||||
expect(mapRefundStatus("REJECTED")).toBe("failed");
|
||||
expect(mapRefundStatus("FAILED")).toBe("failed");
|
||||
expect(mapRefundStatus("")).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRefund", () => {
|
||||
it("maps a refund with a reason to requested_by_customer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = mapRefund(baseRefund());
|
||||
expect(r.id).toBe("sq_ref_123");
|
||||
expect(r.paymentIntentId).toBe("sq_pay_123");
|
||||
expect(r.amount).toEqual({ amount: 2000n, currency: "USD" });
|
||||
expect(r.status).toBe("succeeded");
|
||||
expect(r.reason).toBe("requested_by_customer");
|
||||
expect(r.createdAt).toBe("2026-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("falls back to other when reason is absent", { timeout: 5000 }, () => {
|
||||
const undef = mapRefund(baseRefund({ reason: undefined }));
|
||||
expect(undef.reason).toBe("other");
|
||||
const empty = mapRefund(baseRefund({ reason: "" }));
|
||||
expect(empty.reason).toBe("other");
|
||||
});
|
||||
|
||||
it("carries the mapped status from mapRefundStatus", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapRefund(baseRefund({ status: "PENDING" })).status).toBe(
|
||||
"pending"
|
||||
);
|
||||
expect(mapRefund(baseRefund({ status: "REJECTED" })).status).toBe(
|
||||
"failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Refund, RefundStatus } from "@suaveplan/payment";
|
||||
import type { SquareMoney } from "./payment.js";
|
||||
|
||||
/**
|
||||
* Subset of Square's `Refund` envelope this package consumes. Square
|
||||
* documents additional fields (processing_fee, etc.) the genesis
|
||||
* {@link Refund} shape does not surface.
|
||||
*/
|
||||
export interface SquareRefundDto {
|
||||
readonly id: string;
|
||||
readonly payment_id: string;
|
||||
readonly amount_money: SquareMoney;
|
||||
readonly status: string;
|
||||
readonly created_at: string;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Square's refund status to the genesis {@link RefundStatus}
|
||||
* union. `COMPLETED` → `"succeeded"`, `PENDING` → `"pending"`,
|
||||
* everything else (including `REJECTED` and `FAILED`) → `"failed"`.
|
||||
*/
|
||||
export function mapRefundStatus(squareRefundStatus: string): RefundStatus {
|
||||
if (squareRefundStatus === "COMPLETED") return "succeeded";
|
||||
if (squareRefundStatus === "PENDING") return "pending";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a Square `Refund` envelope into a genesis {@link Refund}.
|
||||
* Square's refund payload carries `reason` only when the merchant
|
||||
* supplied one; the mapping defaults to `"other"` otherwise so the
|
||||
* downstream `RefundReason` union is always populated.
|
||||
*/
|
||||
export function mapRefund(r: SquareRefundDto): Refund {
|
||||
return {
|
||||
id: r.id,
|
||||
paymentIntentId: r.payment_id,
|
||||
amount: {
|
||||
amount: BigInt(r.amount_money.amount),
|
||||
currency: r.amount_money.currency,
|
||||
},
|
||||
status: mapRefundStatus(r.status),
|
||||
reason:
|
||||
typeof r.reason === "string" && r.reason.length > 0
|
||||
? "requested_by_customer"
|
||||
: "other",
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SquareProvider } from "./square.js";
|
||||
@@ -0,0 +1,175 @@
|
||||
# SquareProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SquareProvider` is the Square Payments API adapter for
|
||||
`@suaveplan/payment`. It implements the structural `PaymentProvider`
|
||||
contract with `name: "square"` (narrowed at the type level) and
|
||||
routes every outbound HTTP call through the
|
||||
`@suaveplan/payment-providers` foundation's `httpRequest`, which
|
||||
handles JSON serialisation, the per-call timeout signal, external
|
||||
`AbortSignal` composition, and typed `PaymentProviderError` raising.
|
||||
|
||||
The provider preserves the upstream `square.ts` surface verbatim — the
|
||||
six lifecycle methods (`createIntent`, `confirmIntent`, `captureIntent`,
|
||||
`cancelIntent`, `refund`, `getIntent`), the canonical request bodies,
|
||||
and the status mapping — while wiring the previously-hard-coded
|
||||
`globalThis.crypto.randomUUID()` call onto the foundation's injectable
|
||||
`RandomIdFn` seam. Tests pass a deterministic counter instead of
|
||||
monkey-patching `globalThis.fetch`.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `PaymentProvider` with literal `name: "square"`.
|
||||
- Sends `Authorization: Bearer <apiKey>` and `Square-Version` on every
|
||||
request (`Square-Version` defaults to `2024-01-18`; override via
|
||||
`SquareConfig.apiVersion`).
|
||||
- Sandbox toggle (`sandbox: true`) flips the default base URL to
|
||||
`https://connect.squareupsandbox.com/v2`; an explicit `baseUrl`
|
||||
always wins.
|
||||
- Idempotency keys filled from the injected `randomId` seam when the
|
||||
caller omits one — deterministic in tests, `crypto.randomUUID()` by
|
||||
default in production.
|
||||
- Multi-location merchants supported via the required
|
||||
`SquareConfig.locationId` field, sent as `location_id` on every
|
||||
`CreatePayment` body.
|
||||
- All errors raised as `PaymentProviderError` (foundation contract);
|
||||
the package itself never throws `Error` instances directly.
|
||||
- Universal — no `node:`, `bun:`, `fs`, `path`, `crypto` imports.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const square = new SquareProvider({
|
||||
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
|
||||
locationId: process.env.SQUARE_LOCATION_ID ?? "",
|
||||
});
|
||||
|
||||
const intent = await square.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
description: "Order #123",
|
||||
});
|
||||
|
||||
const captured = await square.captureIntent(intent.id);
|
||||
console.log(captured.status); // "captured"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject deterministic seams (FetchLike + RandomIdFn) for testing,
|
||||
target the sandbox, pin the API version, and compose with an external
|
||||
`AbortSignal`:
|
||||
|
||||
```ts
|
||||
import type { FetchLike } from "@suaveplan/payment-providers";
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const controller = new AbortController();
|
||||
const fakeFetch: FetchLike = async (url, init) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
payment: {
|
||||
id: "sq_pay_1",
|
||||
status: "COMPLETED",
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
const square = new SquareProvider({
|
||||
apiKey: "sandbox-token",
|
||||
locationId: "L1",
|
||||
sandbox: true,
|
||||
apiVersion: "2024-09-19",
|
||||
fetch: fakeFetch,
|
||||
signal: controller.signal,
|
||||
randomId: () => "deterministic-idempotency-key",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const intent = await square.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SquareProvider implements PaymentProvider`
|
||||
|
||||
#### `constructor(config: SquareConfig)`
|
||||
|
||||
Accepts a `SquareConfig` (see `../types/config.md`). Required:
|
||||
`apiKey`, `locationId`. Optional: `baseUrl`, `sandbox`, `apiVersion`,
|
||||
`randomId`, `fetch`, `signal`, `timeoutMs`, `webhookSecret`.
|
||||
|
||||
#### `readonly name: "square"`
|
||||
|
||||
Literal — narrows at the type level for discriminated unions.
|
||||
|
||||
#### `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments` with `amount_money`, `idempotency_key`
|
||||
(falls back to `randomId()`), `autocomplete` (`true` unless
|
||||
`captureMethod === "manual"`), `location_id`, and the optional
|
||||
`customer_id`, `note`, `source_id` fields. Returns the mapped
|
||||
`PaymentIntent`.
|
||||
|
||||
#### `confirmIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
No-op for Square (payments are auto-confirmed). Delegates to
|
||||
`getIntent(intentId)`.
|
||||
|
||||
#### `captureIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments/<intentId>/complete` with no body.
|
||||
|
||||
#### `cancelIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments/<intentId>/cancel` with no body.
|
||||
|
||||
#### `refund(input: RefundInput): Promise<Refund>`
|
||||
|
||||
POSTs `<baseUrl>/refunds` with `payment_id`, `idempotency_key`
|
||||
(falls back to `randomId()`), optional `amount_money`, and optional
|
||||
`reason`. Returns the mapped `Refund`.
|
||||
|
||||
#### `getIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
GETs `<baseUrl>/payments/<intentId>`.
|
||||
|
||||
### Throws
|
||||
|
||||
Every method throws `PaymentProviderError` on HTTP failure, timeout,
|
||||
or JSON parse failure (raised by the foundation's `httpRequest`).
|
||||
`err.provider` is always `"square"`; `err.httpStatus` is set when the
|
||||
failure originated from a non-ok response.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The provider stores its `FetchLike` / `AbortSignal` / `timeoutMs` /
|
||||
`randomId` once at construction and passes them through to every
|
||||
`httpRequest` call so behaviour is deterministic across method
|
||||
invocations.
|
||||
- `location_id` is sent on `CreatePayment` only — Square does not
|
||||
require it on `CompletePayment` / `CancelPayment` / `Refund` calls.
|
||||
- The default `randomId` is a closure that calls `crypto.randomUUID()`
|
||||
at call time. Runtimes without a global `crypto` MUST inject their
|
||||
own `RandomIdFn`.
|
||||
- The class does not duplicate webhook verification logic; Square
|
||||
webhook signatures are verified by `@suaveplan/payment`'s
|
||||
`WebhookVerifier`, which is provider-agnostic.
|
||||
- All formatting flows through the foundation: caller-supplied
|
||||
`Content-Type` / `Accept` are preserved by `httpRequest`'s shallow
|
||||
header merge, but the Square-specific `Authorization` and
|
||||
`Square-Version` headers always win on collision.
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { PaymentProvider } from "@suaveplan/payment";
|
||||
import {
|
||||
type FetchLike,
|
||||
PAYMENT_PROVIDER_CODES,
|
||||
PaymentProviderError,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { SquareConfig } from "../types/config.js";
|
||||
import { SquareProvider } from "./square.js";
|
||||
|
||||
interface Recorded {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
readonly fetch: FetchLike;
|
||||
readonly calls: Recorded[];
|
||||
}
|
||||
|
||||
function recorder(
|
||||
handler: (
|
||||
url: string,
|
||||
init: RequestInit | undefined
|
||||
) => Response | Promise<Response>
|
||||
): Recorder {
|
||||
const calls: Recorded[] = [];
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
};
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SQUARE_PAYMENT = {
|
||||
id: "sq_pay_123",
|
||||
status: "COMPLETED" as const,
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
customer_id: "cust_1",
|
||||
note: "Test payment",
|
||||
card_details: { card: { card_brand: "VISA", last_4: "1234" } },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const SQUARE_REFUND = {
|
||||
id: "sq_ref_123",
|
||||
payment_id: "sq_pay_123",
|
||||
amount_money: { amount: 2000, currency: "USD" },
|
||||
status: "COMPLETED" as const,
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
reason: "Customer request",
|
||||
};
|
||||
|
||||
function counterRandomId(): {
|
||||
next: () => string;
|
||||
fn: () => string;
|
||||
} {
|
||||
let i = 0;
|
||||
const fn = (): string => {
|
||||
i += 1;
|
||||
return `idem-${i}`;
|
||||
};
|
||||
return { next: fn, fn };
|
||||
}
|
||||
|
||||
function build(
|
||||
overrides: Partial<SquareConfig> & { readonly fetch: FetchLike }
|
||||
): SquareProvider {
|
||||
return new SquareProvider({
|
||||
apiKey: "sq_key_123",
|
||||
locationId: "L1",
|
||||
baseUrl: "https://square.mock/v2",
|
||||
randomId: () => "idem-default",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SquareProvider", () => {
|
||||
it('name is the string literal "square"', { timeout: 5000 }, () => {
|
||||
const p = build({ fetch: recorder(() => jsonResponse({})).fetch });
|
||||
expect(p.name).toBe("square");
|
||||
expectTypeOf(p.name).toEqualTypeOf<"square">();
|
||||
});
|
||||
|
||||
it("is assignable to the PaymentProvider interface", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = build({ fetch: recorder(() => jsonResponse({})).fetch });
|
||||
const asProvider: PaymentProvider = p;
|
||||
expect(typeof asProvider.createIntent).toBe("function");
|
||||
expect(typeof asProvider.confirmIntent).toBe("function");
|
||||
expect(typeof asProvider.captureIntent).toBe("function");
|
||||
expect(typeof asProvider.cancelIntent).toBe("function");
|
||||
expect(typeof asProvider.refund).toBe("function");
|
||||
expect(typeof asProvider.getIntent).toBe("function");
|
||||
});
|
||||
|
||||
describe("base URL resolution", () => {
|
||||
it("sandbox flag flips the default base URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = new SquareProvider({
|
||||
apiKey: "k",
|
||||
locationId: "L1",
|
||||
sandbox: true,
|
||||
fetch: rec.fetch,
|
||||
});
|
||||
await p.getIntent("sq_pay_123");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://connect.squareupsandbox.com/v2/payments/sq_pay_123"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the production base URL by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = new SquareProvider({
|
||||
apiKey: "k",
|
||||
locationId: "L1",
|
||||
fetch: rec.fetch,
|
||||
});
|
||||
await p.getIntent("sq_pay_123");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://connect.squareup.com/v2/payments/sq_pay_123"
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit baseUrl wins over sandbox flag", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = new SquareProvider({
|
||||
apiKey: "k",
|
||||
locationId: "L1",
|
||||
sandbox: true,
|
||||
baseUrl: "https://square.mock/v2",
|
||||
fetch: rec.fetch,
|
||||
});
|
||||
await p.getIntent("sq_pay_123");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://square.mock/v2/payments/sq_pay_123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headers", () => {
|
||||
it("sends Authorization: Bearer <apiKey> on every request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
await p.getIntent("sq_pay_123");
|
||||
const headers = rec.calls[0]?.init?.headers as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers.Authorization).toBe("Bearer sq_key_123");
|
||||
});
|
||||
|
||||
it("defaults Square-Version to 2024-01-18", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
await p.getIntent("sq_pay_123");
|
||||
const headers = rec.calls[0]?.init?.headers as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Square-Version"]).toBe("2024-01-18");
|
||||
});
|
||||
|
||||
it("pins the Square-Version header to the configured apiVersion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({
|
||||
fetch: rec.fetch,
|
||||
apiVersion: "2024-09-19",
|
||||
});
|
||||
await p.getIntent("sq_pay_123");
|
||||
const headers = rec.calls[0]?.init?.headers as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Square-Version"]).toBe("2024-09-19");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIntent", () => {
|
||||
it("createIntent posts the canonical body for an automatic-capture intent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
customerId: "c1",
|
||||
description: "test",
|
||||
paymentMethodId: "src_1",
|
||||
idempotencyKey: "idem_1",
|
||||
});
|
||||
expect(result.id).toBe("sq_pay_123");
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(body).toEqual({
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
idempotency_key: "idem_1",
|
||||
autocomplete: true,
|
||||
location_id: "L1",
|
||||
customer_id: "c1",
|
||||
note: "test",
|
||||
source_id: "src_1",
|
||||
});
|
||||
expect(rec.calls[0]?.url).toBe("https://square.mock/v2/payments");
|
||||
expect(rec.calls[0]?.init?.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("createIntent flips autocomplete to false for manual capture", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({
|
||||
payment: {
|
||||
...SQUARE_PAYMENT,
|
||||
status: "APPROVED",
|
||||
approved_money: { amount: 5000, currency: "USD" },
|
||||
},
|
||||
})
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
});
|
||||
expect(result.status).toBe("authorized");
|
||||
expect(result.captureMethod).toBe("manual");
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(body.autocomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("createIntent fills a missing idempotencyKey from the injected randomId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const counter = counterRandomId();
|
||||
const p = build({ fetch: rec.fetch, randomId: counter.fn });
|
||||
await p.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(body.idempotency_key).toBe("idem-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmIntent", () => {
|
||||
it("confirmIntent delegates to getIntent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
await p.confirmIntent("sq_pay_123");
|
||||
expect(rec.calls).toHaveLength(1);
|
||||
expect(rec.calls[0]?.init?.method).toBe("GET");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://square.mock/v2/payments/sq_pay_123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureIntent", () => {
|
||||
it("captureIntent posts to /payments/{id}/complete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.captureIntent("sq_pay_123");
|
||||
expect(result.status).toBe("captured");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://square.mock/v2/payments/sq_pay_123/complete"
|
||||
);
|
||||
expect(rec.calls[0]?.init?.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelIntent", () => {
|
||||
it("cancelIntent posts to /payments/{id}/cancel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({
|
||||
payment: { ...SQUARE_PAYMENT, status: "CANCELED" },
|
||||
})
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.cancelIntent("sq_pay_123");
|
||||
expect(result.status).toBe("canceled");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://square.mock/v2/payments/sq_pay_123/cancel"
|
||||
);
|
||||
expect(rec.calls[0]?.init?.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refund", () => {
|
||||
it("refund posts the full body with amount and reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() => jsonResponse({ refund: SQUARE_REFUND }));
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.refund({
|
||||
paymentIntentId: "sq_pay_123",
|
||||
amount: { amount: 2000n, currency: "USD" },
|
||||
reason: "requested_by_customer",
|
||||
idempotencyKey: "ref_idem_1",
|
||||
});
|
||||
expect(result.id).toBe("sq_ref_123");
|
||||
expect(result.status).toBe("succeeded");
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(body).toEqual({
|
||||
payment_id: "sq_pay_123",
|
||||
idempotency_key: "ref_idem_1",
|
||||
amount_money: { amount: 2000, currency: "USD" },
|
||||
reason: "requested_by_customer",
|
||||
});
|
||||
expect(rec.calls[0]?.url).toBe("https://square.mock/v2/refunds");
|
||||
});
|
||||
|
||||
it("refund fills a missing idempotencyKey from the injected randomId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() => jsonResponse({ refund: SQUARE_REFUND }));
|
||||
const counter = counterRandomId();
|
||||
const p = build({ fetch: rec.fetch, randomId: counter.fn });
|
||||
await p.refund({ paymentIntentId: "sq_pay_123" });
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(body).toEqual({
|
||||
payment_id: "sq_pay_123",
|
||||
idempotency_key: "idem-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntent", () => {
|
||||
it("getIntent fetches /payments/{id}", { timeout: 5000 }, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
const result = await p.getIntent("sq_pay_123");
|
||||
expect(result.id).toBe("sq_pay_123");
|
||||
expect(rec.calls).toHaveLength(1);
|
||||
expect(rec.calls[0]?.init?.method).toBe("GET");
|
||||
expect(rec.calls[0]?.url).toBe(
|
||||
"https://square.mock/v2/payments/sq_pay_123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error surfacing", () => {
|
||||
it("non-ok HTTP response surfaces a typed PaymentProviderError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ message: "Not Found" }, 404)
|
||||
);
|
||||
const p = build({ fetch: rec.fetch });
|
||||
await expect(p.getIntent("sq_pay_missing")).rejects.toSatisfy(
|
||||
(err: unknown) => {
|
||||
if (!(err instanceof PaymentProviderError)) return false;
|
||||
return (
|
||||
err.code === PAYMENT_PROVIDER_CODES.HTTP_ERROR &&
|
||||
err.httpStatus === 404 &&
|
||||
err.provider === "square"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default randomId", () => {
|
||||
it("falls back to crypto.randomUUID() when no randomId is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder(() =>
|
||||
jsonResponse({ payment: SQUARE_PAYMENT })
|
||||
);
|
||||
const p = new SquareProvider({
|
||||
apiKey: "k",
|
||||
locationId: "L1",
|
||||
baseUrl: "https://square.mock/v2",
|
||||
fetch: rec.fetch,
|
||||
});
|
||||
await p.createIntent({
|
||||
amount: { amount: 100n, currency: "USD" },
|
||||
});
|
||||
const body = JSON.parse(rec.calls[0]?.init?.body as string);
|
||||
expect(typeof body.idempotency_key).toBe("string");
|
||||
// crypto.randomUUID() returns a canonical UUID v4 string.
|
||||
expect(body.idempotency_key).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
PaymentProvider,
|
||||
Refund,
|
||||
RefundInput,
|
||||
} from "@suaveplan/payment";
|
||||
import {
|
||||
type FetchLike,
|
||||
httpRequest,
|
||||
type RandomIdFn,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { mapPayment, type SquarePaymentDto } from "../mapping/payment.js";
|
||||
import { mapRefund, type SquareRefundDto } from "../mapping/refund.js";
|
||||
import {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_DEFAULT_BASE_URL,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
type SquareConfig,
|
||||
} from "../types/config.js";
|
||||
|
||||
const PROVIDER_NAME = "square" as const;
|
||||
|
||||
function defaultRandomId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function resolveBaseUrl(config: SquareConfig): string {
|
||||
if (config.baseUrl !== undefined) {
|
||||
return config.baseUrl;
|
||||
}
|
||||
return config.sandbox === true
|
||||
? SQUARE_SANDBOX_BASE_URL
|
||||
: SQUARE_DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
interface PaymentEnvelope {
|
||||
readonly payment: SquarePaymentDto;
|
||||
}
|
||||
|
||||
interface RefundEnvelope {
|
||||
readonly refund: SquareRefundDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square Payments API adapter implementing the
|
||||
* {@link PaymentProvider} interface. Built on the
|
||||
* `@suaveplan/payment-providers` foundation — every outbound HTTP call
|
||||
* goes through `httpRequest`, which handles JSON serialisation, the
|
||||
* per-call timeout signal, external-`AbortSignal` composition, and
|
||||
* typed `PaymentProviderError` raising.
|
||||
*/
|
||||
export class SquareProvider implements PaymentProvider {
|
||||
public readonly name: "square" = PROVIDER_NAME;
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiVersion: string;
|
||||
private readonly randomId: RandomIdFn;
|
||||
private readonly fetchImpl: FetchLike | undefined;
|
||||
private readonly timeoutMs: number | undefined;
|
||||
private readonly externalSignal: AbortSignal | undefined;
|
||||
private readonly locationId: string;
|
||||
|
||||
public constructor(config: SquareConfig) {
|
||||
this.apiKey = config.apiKey;
|
||||
this.baseUrl = resolveBaseUrl(config);
|
||||
this.apiVersion = config.apiVersion ?? SQUARE_DEFAULT_API_VERSION;
|
||||
this.randomId = config.randomId ?? defaultRandomId;
|
||||
this.fetchImpl = config.fetch;
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
this.externalSignal = config.signal;
|
||||
this.locationId = config.locationId;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
input: CreatePaymentIntentInput
|
||||
): Promise<PaymentIntent> {
|
||||
const body: Record<string, unknown> = {
|
||||
amount_money: {
|
||||
amount: Number(input.amount.amount),
|
||||
currency: input.amount.currency,
|
||||
},
|
||||
idempotency_key: input.idempotencyKey ?? this.randomId(),
|
||||
autocomplete: input.captureMethod !== "manual",
|
||||
location_id: this.locationId,
|
||||
};
|
||||
if (input.customerId !== undefined) {
|
||||
body["customer_id"] = input.customerId;
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
body["note"] = input.description;
|
||||
}
|
||||
if (input.paymentMethodId !== undefined) {
|
||||
body["source_id"] = input.paymentMethodId;
|
||||
}
|
||||
|
||||
const { data } = await this.post<PaymentEnvelope>("/payments", body);
|
||||
return mapPayment(data.payment);
|
||||
}
|
||||
|
||||
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
|
||||
return this.getIntent(intentId);
|
||||
}
|
||||
|
||||
public async captureIntent(intentId: string): Promise<PaymentIntent> {
|
||||
const { data } = await this.post<PaymentEnvelope>(
|
||||
`/payments/${intentId}/complete`,
|
||||
undefined
|
||||
);
|
||||
return mapPayment(data.payment);
|
||||
}
|
||||
|
||||
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
|
||||
const { data } = await this.post<PaymentEnvelope>(
|
||||
`/payments/${intentId}/cancel`,
|
||||
undefined
|
||||
);
|
||||
return mapPayment(data.payment);
|
||||
}
|
||||
|
||||
public async refund(input: RefundInput): Promise<Refund> {
|
||||
const body: Record<string, unknown> = {
|
||||
payment_id: input.paymentIntentId,
|
||||
idempotency_key: input.idempotencyKey ?? this.randomId(),
|
||||
};
|
||||
if (input.amount !== undefined) {
|
||||
body["amount_money"] = {
|
||||
amount: Number(input.amount.amount),
|
||||
currency: input.amount.currency,
|
||||
};
|
||||
}
|
||||
if (input.reason !== undefined) {
|
||||
body["reason"] = input.reason;
|
||||
}
|
||||
|
||||
const { data } = await this.post<RefundEnvelope>("/refunds", body);
|
||||
return mapRefund(data.refund);
|
||||
}
|
||||
|
||||
public async getIntent(intentId: string): Promise<PaymentIntent> {
|
||||
const { data } = await httpRequest<PaymentEnvelope>({
|
||||
method: "GET",
|
||||
url: `${this.baseUrl}/payments/${intentId}`,
|
||||
headers: this.headers(),
|
||||
fetch: this.fetchImpl,
|
||||
signal: this.externalSignal,
|
||||
timeoutMs: this.timeoutMs,
|
||||
provider: this.name,
|
||||
});
|
||||
return mapPayment(data.payment);
|
||||
}
|
||||
|
||||
private async post<T>(
|
||||
path: string,
|
||||
body: Record<string, unknown> | undefined
|
||||
): Promise<{ readonly data: T }> {
|
||||
return httpRequest<T>({
|
||||
method: "POST",
|
||||
url: `${this.baseUrl}${path}`,
|
||||
headers: this.headers(),
|
||||
body,
|
||||
fetch: this.fetchImpl,
|
||||
signal: this.externalSignal,
|
||||
timeoutMs: this.timeoutMs,
|
||||
provider: this.name,
|
||||
});
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Square-Version": this.apiVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# SquareConfig and default constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the configuration shape `SquareProvider` accepts and the three
|
||||
default constants the provider falls back to when the caller omits the
|
||||
corresponding optional field. The interface extends the shared
|
||||
`ApiKeyConfig` from `@suaveplan/payment-providers` so every common
|
||||
injectable seam — `fetch`, `timeoutMs`, `signal`, `baseUrl`,
|
||||
`webhookSecret` — is inherited rather than redeclared.
|
||||
|
||||
## Features
|
||||
|
||||
- `SquareConfig extends ApiKeyConfig` with `locationId: string`
|
||||
(required), `sandbox?: boolean`, `apiVersion?: string`, and
|
||||
`randomId?: RandomIdFn`.
|
||||
- Three exported constants pin the production base URL, the sandbox
|
||||
base URL, and the default `Square-Version` header value so they
|
||||
show up exactly once across the package and are auditable.
|
||||
- Type-only — the file ships no runtime objects beyond the constants;
|
||||
the interface is erased at build time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SquareConfig } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const config: SquareConfig = {
|
||||
apiKey: "sq0atp-...",
|
||||
locationId: "L1234567890",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Pin every optional field — useful for the sandbox + deterministic
|
||||
testing combination:
|
||||
|
||||
```ts
|
||||
import type { SquareConfig } from "@suaveplan/payment-provider-square";
|
||||
import {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
const config: SquareConfig = {
|
||||
apiKey: "sq0atp-sandbox-...",
|
||||
locationId: "L_SANDBOX",
|
||||
sandbox: true,
|
||||
apiVersion: SQUARE_DEFAULT_API_VERSION,
|
||||
randomId: () => "deterministic-id",
|
||||
timeoutMs: 5_000,
|
||||
baseUrl: SQUARE_SANDBOX_BASE_URL, // optional — sandbox flag picks this up
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface SquareConfig extends ApiKeyConfig`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | `string` | yes | Inherited from `ApiKeyConfig`. |
|
||||
| `locationId` | `string` | yes | Square `location_id` sent on every `CreatePayment` body. |
|
||||
| `sandbox` | `boolean` | no | When `true`, flips the default base URL to `SQUARE_SANDBOX_BASE_URL`. |
|
||||
| `apiVersion` | `string` | no | Value of the `Square-Version` header. Defaults to `SQUARE_DEFAULT_API_VERSION`. |
|
||||
| `randomId` | `RandomIdFn` | no | Generator for missing idempotency keys. Defaults to `crypto.randomUUID()`. |
|
||||
| `baseUrl` | `string` | no | Inherited from `ApiKeyConfig`. Wins over `sandbox`. |
|
||||
| `webhookSecret` | `string` | no | Inherited from `ApiKeyConfig`. Not used by the provider itself. |
|
||||
| `fetch` | `FetchLike` | no | Inherited from `ProviderCommonOptions`. |
|
||||
| `timeoutMs` | `number` | no | Inherited from `ProviderCommonOptions`. |
|
||||
| `signal` | `AbortSignal` | no | Inherited from `ProviderCommonOptions`. |
|
||||
|
||||
### `const SQUARE_DEFAULT_BASE_URL`
|
||||
|
||||
`"https://connect.squareup.com/v2"` — Square production base URL.
|
||||
|
||||
### `const SQUARE_SANDBOX_BASE_URL`
|
||||
|
||||
`"https://connect.squareupsandbox.com/v2"` — Square sandbox base URL.
|
||||
|
||||
### `const SQUARE_DEFAULT_API_VERSION`
|
||||
|
||||
`"2024-01-18"` — default `Square-Version` header value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `locationId` is intentionally required (the upstream
|
||||
`payment-providers` Square adapter omitted it; consumers had to
|
||||
wrap the provider to inject it). Multi-location merchants are the
|
||||
common case, and Square's `CreatePayment` endpoint rejects requests
|
||||
that target the wrong location implicitly.
|
||||
- The `sandbox` flag is a convenience — callers who need exotic base
|
||||
URLs (Square Connect for the EU, an internal proxy, etc.) should
|
||||
set `baseUrl` directly.
|
||||
- `randomId` is shaped as a synchronous `() => string` rather than a
|
||||
promise-returning function. Square's idempotency keys are opaque;
|
||||
any unique-enough string works.
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ApiKeyConfig } from "@suaveplan/payment-providers";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_DEFAULT_BASE_URL,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
type SquareConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("SquareConfig", () => {
|
||||
it("SquareConfig extends ApiKeyConfig", { timeout: 5000 }, () => {
|
||||
expectTypeOf<SquareConfig>().toMatchTypeOf<ApiKeyConfig>();
|
||||
});
|
||||
|
||||
it("locationId is a required field at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
type WithoutLocation = Omit<SquareConfig, "locationId">;
|
||||
expectTypeOf<WithoutLocation>().not.toMatchTypeOf<SquareConfig>();
|
||||
expectTypeOf<SquareConfig>()
|
||||
.toHaveProperty("locationId")
|
||||
.toEqualTypeOf<string>();
|
||||
});
|
||||
|
||||
it("exposes sandbox / apiVersion / randomId as optional", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const minimal: SquareConfig = {
|
||||
apiKey: "sq_test",
|
||||
locationId: "L1",
|
||||
};
|
||||
expect(minimal.sandbox).toBeUndefined();
|
||||
expect(minimal.apiVersion).toBeUndefined();
|
||||
expect(minimal.randomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Square default constants", () => {
|
||||
it("exports the documented default base URLs", { timeout: 5000 }, () => {
|
||||
expect(SQUARE_DEFAULT_BASE_URL).toBe("https://connect.squareup.com/v2");
|
||||
expect(SQUARE_SANDBOX_BASE_URL).toBe(
|
||||
"https://connect.squareupsandbox.com/v2"
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the default Square-Version to 2024-01-18", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SQUARE_DEFAULT_API_VERSION).toBe("2024-01-18");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ApiKeyConfig, RandomIdFn } from "@suaveplan/payment-providers";
|
||||
|
||||
/**
|
||||
* Default production base URL for the Square Payments API. Used when
|
||||
* neither `baseUrl` nor `sandbox: true` is supplied on the
|
||||
* `SquareConfig`.
|
||||
*/
|
||||
export const SQUARE_DEFAULT_BASE_URL = "https://connect.squareup.com/v2";
|
||||
|
||||
/**
|
||||
* Default Square sandbox base URL. Used when `sandbox: true` is set on
|
||||
* the `SquareConfig` and no explicit `baseUrl` overrides it.
|
||||
*/
|
||||
export const SQUARE_SANDBOX_BASE_URL = "https://connect.squareupsandbox.com/v2";
|
||||
|
||||
/**
|
||||
* Default `Square-Version` header value. Square requires this header
|
||||
* on every request. Pin a newer value via `SquareConfig.apiVersion` to
|
||||
* opt into a more recent API surface.
|
||||
*/
|
||||
export const SQUARE_DEFAULT_API_VERSION = "2024-01-18";
|
||||
|
||||
/**
|
||||
* Configuration object for {@link SquareProvider}. Extends the shared
|
||||
* `ApiKeyConfig` from `@suaveplan/payment-providers` with the
|
||||
* Square-specific fields the Payments API requires (the
|
||||
* `location_id` carried on every `CreatePayment` body) and convenience
|
||||
* toggles (`sandbox` for the sandbox base URL flip, `apiVersion` for
|
||||
* pinning the `Square-Version` header, `randomId` for deterministic
|
||||
* idempotency-key generation in tests).
|
||||
*/
|
||||
export interface SquareConfig extends ApiKeyConfig {
|
||||
/**
|
||||
* Square `location_id` carried on every `CreatePayment` body.
|
||||
* Required because Square's `CreatePayment` endpoint rejects
|
||||
* single-location assumptions for multi-location merchants.
|
||||
*/
|
||||
readonly locationId: string;
|
||||
/**
|
||||
* When `true`, requests target {@link SQUARE_SANDBOX_BASE_URL}
|
||||
* instead of {@link SQUARE_DEFAULT_BASE_URL}. An explicit `baseUrl`
|
||||
* on the parent `ApiKeyConfig` always wins over this flag.
|
||||
*/
|
||||
readonly sandbox?: boolean | undefined;
|
||||
/**
|
||||
* Value of the `Square-Version` header sent on every request.
|
||||
* Defaults to {@link SQUARE_DEFAULT_API_VERSION}.
|
||||
*/
|
||||
readonly apiVersion?: string | undefined;
|
||||
/**
|
||||
* Injectable id generator used to fill missing
|
||||
* `CreatePayment.idempotency_key` / `RefundPayment.idempotency_key`
|
||||
* fields. Defaults to `() => crypto.randomUUID()` at call time so
|
||||
* runtimes without a global `crypto` can inject their own. Tests
|
||||
* pass a deterministic counter.
|
||||
*/
|
||||
readonly randomId?: RandomIdFn | undefined;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_DEFAULT_BASE_URL,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
type SquareConfig,
|
||||
} from "./config.js";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..", "src");
|
||||
|
||||
function walkTypescript(dir: string): string[] {
|
||||
const entries = readdirSync(dir);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
files.push(...walkTypescript(full));
|
||||
} else if (
|
||||
entry.endsWith(".ts") &&
|
||||
!entry.endsWith(".test.ts") &&
|
||||
!entry.endsWith(".spec.ts")
|
||||
) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("universal dep graph", () => {
|
||||
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const banned =
|
||||
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
|
||||
const offenders: string[] = [];
|
||||
for (const file of walkTypescript(SRC_ROOT)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
if (banned.test(text)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Polyfills for the payment-provider-square test environment — none required.
|
||||
// Every test injects its own FetchLike + randomId; no platform globals are
|
||||
// touched. This file exists to mirror the sibling-package layout and to give
|
||||
// vitest a stable setup-file path.
|
||||
export {};
|
||||
12
packages/money/payment/payment-provider-square/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-square/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/payment-provider-square",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/payment",
|
||||
"@suaveplan/payment-providers",
|
||||
"tslib",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user