Initial Commit
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user