Files
suaveplan/openspec/specs/payment-provider-square/spec.md
2026-05-27 23:29:53 +01:00

266 lines
16 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: payment-provider-square (add-payment-provider-square)
**Package**: `@suaveplan/payment-provider-square`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: SquareProvider implements PaymentProvider with literal name "square"
The package SHALL export a `SquareProvider` class that implements the `PaymentProvider` interface from `@suaveplan/payment`. The instance SHALL expose `name` as the string literal `"square"` (narrowed at the type level). The class SHALL accept a single `SquareConfig` constructor argument and SHALL NOT mutate it.
#### Scenario: name is the literal "square"
- **WHEN** a `SquareProvider` is constructed with any valid `SquareConfig`
- **THEN** `provider.name === "square"` and the static type of `provider.name` is the literal `"square"` (assertable with `expectTypeOf`)
#### Scenario: assignable to PaymentProvider
- **WHEN** a `SquareProvider` instance is assigned to a `PaymentProvider`-typed binding
- **THEN** the assignment compiles without a cast, and all six methods (`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`) are present
**Verified by:** `src/square/square.test.ts::"name is the string literal \"square\""` AND `src/square/square.test.ts::"is assignable to the PaymentProvider interface"`
---
### Requirement: SquareConfig extends ApiKeyConfig with Square-specific fields
The package SHALL export a `SquareConfig` interface that extends `ApiKeyConfig` (from `@suaveplan/payment-providers`) and adds a required `locationId: string`, an optional `sandbox?: boolean`, an optional `apiVersion?: string`, and an optional `randomId?: RandomIdFn`. The defaults SHALL be: `sandbox = false`, `apiVersion = "2024-01-18"`, `randomId = () => crypto.randomUUID()`. The class SHALL prefer `config.baseUrl` over the sandbox/production default when set.
#### Scenario: locationId is required at the type level
- **WHEN** a caller constructs a `SquareConfig` without `locationId`
- **THEN** the TypeScript compiler rejects the construction (assertable with `expectTypeOf<...>().toMatchTypeOf<...>()`)
#### Scenario: sandbox flag flips the default base URL
- **WHEN** a `SquareProvider` is constructed with `sandbox: true` and no explicit `baseUrl`, and any method is invoked with a recording `FetchLike`
- **THEN** the recorded request URL starts with `https://connect.squareupsandbox.com/v2`
#### Scenario: explicit baseUrl wins over sandbox flag
- **WHEN** a `SquareProvider` is constructed with `sandbox: true` AND `baseUrl: "https://square.mock/v2"`
- **THEN** every recorded request starts with `https://square.mock/v2`
**Verified by:** `src/types/config.test.ts::"locationId is a required field at the type level"` AND `src/types/config.test.ts::"SquareConfig extends ApiKeyConfig"` AND `src/square/square.test.ts::"sandbox flag flips the default base URL"` AND `src/square/square.test.ts::"explicit baseUrl wins over sandbox flag"`
---
### Requirement: Authorization and Square-Version headers on every request
The package SHALL send `Authorization: Bearer <apiKey>` and `Square-Version: <apiVersion>` headers on every HTTP request. The `apiVersion` value SHALL be `config.apiVersion ?? "2024-01-18"`. The headers SHALL be merged with `httpRequest`'s default `Content-Type: application/json` and `Accept: application/json` (foundation behavior). No other Square-specific header SHALL be sent unless documented.
#### Scenario: Authorization header carries the API key
- **WHEN** any provider method is invoked and the recorder captures the outbound `init.headers`
- **THEN** the `Authorization` header equals `"Bearer <apiKey>"`
#### Scenario: Square-Version header pins the API version
- **WHEN** a `SquareProvider` is constructed with `apiVersion: "2024-09-19"` and any method is invoked
- **THEN** every recorded request carries `Square-Version: 2024-09-19`
#### Scenario: default Square-Version is 2024-01-18
- **WHEN** a `SquareProvider` is constructed without an `apiVersion` and any method is invoked
- **THEN** every recorded request carries `Square-Version: 2024-01-18`
**Verified by:** `src/square/square.test.ts::"sends Authorization: Bearer <apiKey> on every request"` AND `src/square/square.test.ts::"pins the Square-Version header to the configured apiVersion"` AND `src/square/square.test.ts::"defaults Square-Version to 2024-01-18"`
---
### Requirement: createIntent posts amount, customer, description, source, autocomplete flag
`createIntent(input)` SHALL POST to `<baseUrl>/payments` with a JSON body whose `amount_money` reflects `input.amount`, `idempotency_key` is `input.idempotencyKey ?? randomId()`, and `autocomplete` is `input.captureMethod !== "manual"`. Optional fields (`customer_id`, `note`, `source_id`) SHALL be set from `input.customerId`, `input.description`, `input.paymentMethodId` only when defined. The returned `PaymentIntent` SHALL be produced by `mapPayment(data.payment)`.
#### Scenario: posts the canonical body for an automatic-capture intent
- **WHEN** `createIntent({ amount: { amount: 5000n, currency: "USD" }, customerId: "c1", description: "test", paymentMethodId: "src_1", idempotencyKey: "idem_1" })` is invoked against a recorder
- **THEN** the recorded `init.body` equals `JSON.stringify({ amount_money: { amount: 5000, currency: "USD" }, idempotency_key: "idem_1", autocomplete: true, customer_id: "c1", note: "test", source_id: "src_1" })` (key order matches the implementation)
#### Scenario: manual capture flips autocomplete to false
- **WHEN** `createIntent` is invoked with `captureMethod: "manual"`
- **THEN** the recorded `init.body` includes `"autocomplete":false`
#### Scenario: missing idempotencyKey is filled from injected randomId
- **WHEN** `createIntent` is invoked without `idempotencyKey` on a provider constructed with `randomId: () => "deterministic-id"`
- **THEN** the recorded `init.body` includes `"idempotency_key":"deterministic-id"`
**Verified by:** `src/square/square.test.ts::"createIntent posts the canonical body for an automatic-capture intent"` AND `src/square/square.test.ts::"createIntent flips autocomplete to false for manual capture"` AND `src/square/square.test.ts::"createIntent fills a missing idempotencyKey from the injected randomId"`
---
### Requirement: confirmIntent delegates to getIntent
Square auto-confirms payments at creation. `confirmIntent(intentId)` SHALL therefore behave as a no-op by delegating to `getIntent(intentId)` and returning the current state.
#### Scenario: confirmIntent issues a GET for the current state
- **WHEN** `confirmIntent("sq_pay_123")` is invoked against a recorder
- **THEN** exactly one HTTP call is recorded with `init.method === "GET"` and a URL ending in `/payments/sq_pay_123`
**Verified by:** `src/square/square.test.ts::"confirmIntent delegates to getIntent"`
---
### Requirement: captureIntent posts to /payments/{id}/complete
`captureIntent(intentId)` SHALL POST to `<baseUrl>/payments/<intentId>/complete` with no body and SHALL return `mapPayment(data.payment)`.
#### Scenario: captures by completing the payment
- **WHEN** `captureIntent("sq_pay_123")` is invoked against a recorder returning a COMPLETED payment
- **THEN** the recorded URL is `<baseUrl>/payments/sq_pay_123/complete`, `init.method === "POST"`, and the returned intent has `status: "captured"`
**Verified by:** `src/square/square.test.ts::"captureIntent posts to /payments/{id}/complete"`
---
### Requirement: cancelIntent posts to /payments/{id}/cancel
`cancelIntent(intentId)` SHALL POST to `<baseUrl>/payments/<intentId>/cancel` with no body and SHALL return `mapPayment(data.payment)`.
#### Scenario: cancels by posting to the cancel endpoint
- **WHEN** `cancelIntent("sq_pay_123")` is invoked against a recorder returning a CANCELED payment
- **THEN** the recorded URL is `<baseUrl>/payments/sq_pay_123/cancel`, `init.method === "POST"`, and the returned intent has `status: "canceled"`
**Verified by:** `src/square/square.test.ts::"cancelIntent posts to /payments/{id}/cancel"`
---
### Requirement: refund posts to /refunds with optional amount and reason
`refund(input)` SHALL POST to `<baseUrl>/refunds` with a JSON body whose `payment_id` is `input.paymentIntentId` and `idempotency_key` is `input.idempotencyKey ?? randomId()`. When `input.amount` is set the body SHALL include an `amount_money` object built from it; when `input.reason` is set the body SHALL include `reason`. The returned `Refund` SHALL be produced by `mapRefund(data.refund)`.
#### Scenario: full refund body with amount and reason
- **WHEN** `refund({ paymentIntentId: "sq_pay_123", amount: { amount: 2000n, currency: "USD" }, reason: "requested_by_customer", idempotencyKey: "ref_idem_1" })` is invoked against a recorder
- **THEN** the recorded `init.body` equals `JSON.stringify({ payment_id: "sq_pay_123", idempotency_key: "ref_idem_1", amount_money: { amount: 2000, currency: "USD" }, reason: "requested_by_customer" })`
#### Scenario: minimal refund body (no amount, no reason) fills idempotency key from randomId
- **WHEN** `refund({ paymentIntentId: "sq_pay_123" })` is invoked on a provider constructed with `randomId: () => "ref-gen"`
- **THEN** the recorded `init.body` equals `JSON.stringify({ payment_id: "sq_pay_123", idempotency_key: "ref-gen" })`
**Verified by:** `src/square/square.test.ts::"refund posts the full body with amount and reason"` AND `src/square/square.test.ts::"refund fills a missing idempotencyKey from the injected randomId"`
---
### Requirement: getIntent fetches /payments/{id}
`getIntent(intentId)` SHALL GET `<baseUrl>/payments/<intentId>` and SHALL return `mapPayment(data.payment)`.
#### Scenario: fetches the payment by id
- **WHEN** `getIntent("sq_pay_123")` is invoked against a recorder
- **THEN** exactly one HTTP call is recorded with `init.method === "GET"` and URL `<baseUrl>/payments/sq_pay_123`
**Verified by:** `src/square/square.test.ts::"getIntent fetches /payments/{id}"`
---
### Requirement: mapPaymentStatus covers every documented Square status
`mapPaymentStatus(squareStatus)` SHALL map `APPROVED` → `"authorized"`, `COMPLETED` → `"captured"`, `CANCELED` → `"canceled"`, `FAILED` → `"failed"`, `PENDING` → `"pending"`. Any unknown string SHALL map to `"failed"` (defensive default — Square may introduce new states between API versions).
#### Scenario: every known status maps to the documented PaymentStatus
- **WHEN** `mapPaymentStatus` is invoked with each of `APPROVED`, `COMPLETED`, `CANCELED`, `FAILED`, `PENDING`
- **THEN** the return values are `authorized`, `captured`, `canceled`, `failed`, `pending` respectively
#### Scenario: unknown status falls back to failed
- **WHEN** `mapPaymentStatus("SOMETHING_NEW")` is invoked
- **THEN** the return value is `"failed"`
**Verified by:** `src/mapping/payment.test.ts::"maps every known Square status to the documented PaymentStatus"` AND `src/mapping/payment.test.ts::"falls back to failed for unknown Square status"`
---
### Requirement: mapPayment shapes the PaymentIntent including captureMethod and paymentMethod branches
`mapPayment(p)` SHALL produce a `PaymentIntent` whose `capturedAmount.amount` equals `BigInt(p.total_money.amount)` when `p.status === "COMPLETED"` and `0n` otherwise. `captureMethod` SHALL be `"manual"` when `p.approved_money` is set and `"automatic"` otherwise. `paymentMethod` SHALL be set to a `PaymentMethod` (`type: "card"`, lower-cased brand, `last4`) when `p.card_details` is present, and `undefined` otherwise. `refundedAmount` SHALL be `{ amount: 0n, currency }` (refund totals are tracked separately by `PaymentEngine`). `refunds` SHALL be an empty array.
#### Scenario: COMPLETED payment with card details
- **WHEN** `mapPayment({ status: "COMPLETED", amount_money: {...}, total_money: {...}, card_details: { card: { card_brand: "VISA", last_4: "1234" } }, ... })` is invoked
- **THEN** the result has `status: "captured"`, `capturedAmount.amount: BigInt(total_money.amount)`, and `paymentMethod: { type: "card", brand: "visa", last4: "1234", id: "sq_card_<id>" }`
#### Scenario: APPROVED payment with approved_money flips captureMethod to manual
- **WHEN** `mapPayment({ status: "APPROVED", approved_money: { amount, currency }, ... })` is invoked
- **THEN** the result has `captureMethod: "manual"` and `capturedAmount.amount: 0n`
#### Scenario: payment without card details has undefined paymentMethod
- **WHEN** `mapPayment({ ..., card_details: undefined })` is invoked
- **THEN** the result's `paymentMethod` is `undefined`
**Verified by:** `src/mapping/payment.test.ts::"maps a COMPLETED payment with card details"` AND `src/mapping/payment.test.ts::"flips captureMethod to manual when approved_money is present"` AND `src/mapping/payment.test.ts::"sets paymentMethod to undefined when card_details is absent"`
---
### Requirement: mapRefundStatus covers every documented refund status
`mapRefundStatus(squareRefundStatus)` SHALL map `COMPLETED` → `"succeeded"`, `PENDING` → `"pending"`, and anything else (including `REJECTED` and `FAILED`) → `"failed"`.
#### Scenario: every known refund status maps correctly
- **WHEN** `mapRefundStatus` is invoked with `COMPLETED`, `PENDING`, `REJECTED`, `FAILED`
- **THEN** the return values are `succeeded`, `pending`, `failed`, `failed` respectively
**Verified by:** `src/mapping/refund.test.ts::"maps every known Square refund status"`
---
### Requirement: mapRefund shapes the Refund and falls back to "other" when reason is absent
`mapRefund(r)` SHALL produce a `Refund` whose `amount` mirrors `r.amount_money` (as a `bigint`), whose `paymentIntentId` is `r.payment_id`, whose `status` comes from `mapRefundStatus(r.status)`, and whose `reason` is `"requested_by_customer"` when `r.reason` is a non-empty string, `"other"` otherwise. `createdAt` SHALL be `r.created_at` verbatim.
#### Scenario: refund with a reason maps to requested_by_customer
- **WHEN** `mapRefund({ ..., reason: "Customer request" })` is invoked
- **THEN** the result has `reason: "requested_by_customer"`
#### Scenario: refund without a reason falls back to other
- **WHEN** `mapRefund({ ..., reason: undefined })` is invoked
- **THEN** the result has `reason: "other"`
**Verified by:** `src/mapping/refund.test.ts::"maps a refund with a reason to requested_by_customer"` AND `src/mapping/refund.test.ts::"falls back to other when reason is absent"`
---
### Requirement: only PaymentProviderError is raised
Every HTTP failure SHALL surface as a `PaymentProviderError` carrying a `PAYMENT_PROVIDER_CODES` code. The package itself SHALL NOT throw plain `Error` instances, SHALL NOT throw string literals, and SHALL NOT wrap foundation errors in a new error type. This obligation is satisfied by routing every outbound call through the foundation's `httpRequest`, which raises typed errors directly.
#### Scenario: non-ok HTTP response surfaces a typed HTTP_ERROR
- **WHEN** `getIntent("sq_pay_123")` is invoked against a recorder that returns `404` with body `{ "message": "Not Found" }`
- **THEN** the awaited promise rejects with a `PaymentProviderError` whose `code === PAYMENT_PROVIDER_CODES.HTTP_ERROR`, `httpStatus === 404`, and `provider === "square"`
**Verified by:** `src/square/square.test.ts::"non-ok HTTP response surfaces a typed PaymentProviderError"`
---
### Requirement: universal-tier purity
The package SHALL NOT import from `node:*`, `bun:*`, `fs`, `path`, `stream`, `net`, `http`, or `crypto` (the bare module names). The package SHALL NOT call `globalThis.crypto.randomUUID()` directly — every id generation goes through the injected `randomId` seam with a default that calls `crypto.randomUUID()` at call time (so runtimes without a global `crypto` can inject their own).
#### Scenario: src/ contains no banned imports
- **WHEN** the `tests/dep-graph.test.ts` sweep walks every `.ts` under `src/`
- **THEN** zero files match the banned-import regex
**Verified by:** `tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"`
<!-- markdownlint-enable MD024 -->