Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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

View 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"
]
}

View 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";

View File

@@ -0,0 +1,12 @@
export {
mapPayment,
mapPaymentStatus,
type SquareCard,
type SquareMoney,
type SquarePaymentDto,
} from "./payment.js";
export {
mapRefund,
mapRefundStatus,
type SquareRefundDto,
} from "./refund.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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"
);
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1 @@
export { SquareProvider } from "./square.js";

View File

@@ -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.

View File

@@ -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
);
});
});
});

View File

@@ -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,
};
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export {
SQUARE_DEFAULT_API_VERSION,
SQUARE_DEFAULT_BASE_URL,
SQUARE_SANDBOX_BASE_URL,
type SquareConfig,
} from "./config.js";

View File

@@ -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([]);
});
});

View File

@@ -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 {};

View 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"]
}

View File

@@ -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"]
}

View File

@@ -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",
],
},
},
});

View File

@@ -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,
},
});