Initial Commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# Add @suaveplan/payment-provider-square
|
||||
|
||||
**Package**: `@suaveplan/payment-provider-square`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
The `2026-05-17-slim-payment-providers` change removed the five concrete provider classes from `@suaveplan/payment-providers` and committed to re-landing each in a dedicated sibling package. Square is the first of those siblings. Shipping it restores the Square Payments API surface that consumers depend on (`SquareProvider`, `SquareConfig`) without re-bundling the unrelated Stripe / PayPal / Mollie / Adyen adapters, and proves the foundation's `httpRequest` + `composeAbortSignal` + `PAYMENT_PROVIDER_CODES` contract works end-to-end for a sibling.
|
||||
|
||||
The upstream implementation (`../suaveplan/packages/universal/payment-providers/src/square.ts`) reaches for `globalThis.crypto.randomUUID()` directly and is exercised by tests that monkey-patch `globalThis.fetch`. Both anti-patterns are banned in genesis (universal tier purity, no mocks). This change ports the surface verbatim while wiring it onto the genesis foundation's injectable `FetchLike` + `RandomIdFn` seams.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: package `@suaveplan/payment-provider-square` at `packages/universal/payment-provider-square/` (version `0.1.0`).
|
||||
- ADDED: `SquareProvider implements PaymentProvider` (class) with literal `name: "square"` — full Square Payments API lifecycle (`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`).
|
||||
- ADDED: `SquareConfig extends ApiKeyConfig` config interface — adds required `locationId: string`, optional `sandbox?: boolean`, optional `randomId?: RandomIdFn`, optional `apiVersion?: string`.
|
||||
- ADDED: helpers `mapPayment`, `mapRefund`, `mapPaymentStatus`, `mapRefundStatus` covering every Square `status` value the API documents.
|
||||
- ADDED: `Square-Version: <apiVersion>` header on every request (defaults to `2024-01-18` — the value the upstream package hard-coded).
|
||||
- ADDED: idempotency-key generation via injectable `randomId()` for `CreatePayment` + `RefundPayment` calls.
|
||||
- ADDED: 5 subpath exports (`.`, `./square/square`, `./types/config`, `./mapping/payment`, `./mapping/refund`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Square Webhooks API (HMAC signature verification) — webhook verification lives in `@suaveplan/payment` (`WebhookVerifier`) and is provider-agnostic. The sibling package does not duplicate it.
|
||||
- The Square Catalog, Customers, or Orders APIs — they are not part of the `PaymentProvider` contract.
|
||||
- The OAuth merchant-onboarding flow — Square's OAuth flow is for delegated multi-merchant scenarios; the sibling assumes the caller already holds a tenant access token (matches the upstream implementation).
|
||||
- Stripe, PayPal, Mollie, Adyen sibling packages — each ships in its own follow-up change.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/payment-providers.md` (the family-wide enterprise plan; Square-specific behavior is captured in the upstream `square.ts` + `square.md` + `square.test.ts` triple).
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| SquareProvider implements PaymentProvider with literal `name: "square"` | `src/square.ts` L97-L108 | verbatim |
|
||||
| Authorization + Square-Version headers on every request | `src/square.ts` L192-L197 | verbatim, version pinned to `2024-01-18` upstream — exposed via `apiVersion` option in genesis |
|
||||
| createIntent maps amount/currency, customer, description, source, autocomplete flag | `src/square.ts` L110-L133 | verbatim |
|
||||
| createIntent supplies an idempotency key (default = `crypto.randomUUID()`) | `src/square.ts` L113-L118 | extended in genesis — `randomId` is injectable instead of reaching for `globalThis.crypto` |
|
||||
| confirmIntent is a no-op that returns `getIntent` (Square auto-confirms) | `src/square.ts` L135-L137 | verbatim |
|
||||
| captureIntent posts to `/payments/{id}/complete` | `src/square.ts` L139-L147 | verbatim |
|
||||
| cancelIntent posts to `/payments/{id}/cancel` | `src/square.ts` L149-L157 | verbatim |
|
||||
| refund posts to `/refunds` with optional `amount_money` and `reason` | `src/square.ts` L159-L180 | verbatim — refund also supplies an idempotency key via `randomId` |
|
||||
| getIntent fetches `/payments/{id}` | `src/square.ts` L182-L190 | verbatim |
|
||||
| Status mapping: APPROVED→authorized, COMPLETED→captured, CANCELED→canceled, FAILED→failed, PENDING→pending, fallback→failed | `src/square.ts` L38-L47 | verbatim |
|
||||
| Refund status mapping: COMPLETED→succeeded, PENDING→pending, fallback→failed | `src/square.ts` L49-L53 | verbatim |
|
||||
| Payment mapping handles missing card details + missing approved_money | `src/square.ts` L55-L81 | verbatim |
|
||||
| Refund mapping reads reason → `requested_by_customer` when present, otherwise `other` | `src/square.ts` L83-L95 | verbatim |
|
||||
| Sandbox base URL fallback | — | extended in genesis (see "no source line" table) |
|
||||
| `locationId` is a required Square-specific config field | — | extended in genesis (see "no source line" table) |
|
||||
| Only `PaymentProviderError` is raised | `src/square.ts` (implicit via `httpRequest`) | inherited from the genesis foundation contract |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `SquareConfig extends ApiKeyConfig` adds `locationId: string` | Square's `CreatePayment` requires a `location_id` for multi-location merchants. The upstream package omitted it (single-location bias); genesis adds it as a required field because shipping without it forces every consumer to hand-roll a wrapper. |
|
||||
| `sandbox?: boolean` flips the default base URL to `https://connect.squareupsandbox.com/v2` | Square's sandbox is a first-class environment with its own host. Exposing a boolean is friendlier than asking callers to flip `baseUrl` themselves. |
|
||||
| `apiVersion?: string` (default `2024-01-18`) | Square requires a `Square-Version` header on every call. The upstream package hard-coded the value; genesis exposes it so consumers can pin a newer version without forking. |
|
||||
| `randomId?: RandomIdFn` injectable seam | Universal-tier purity (CLAUDE.md). Direct `crypto.randomUUID()` calls fail on runtimes without a global `crypto`, and they prevent deterministic tests. |
|
||||
| Universal-tier purity (no `node:crypto`, `node:fs`, etc.) | Tier requirement from CLAUDE.md — enforced by sibling `tests/dep-graph.test.ts`. |
|
||||
| 100% test coverage (istanbul) + sibling `.md` per implementation file | Genesis-wide policy (CLAUDE.md). |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in `specs/payment-provider-square/spec.md` carries a `**Verified by:**` line pointing at concrete `it(...)` titles in:
|
||||
|
||||
- `src/square/square.test.ts` — full provider lifecycle (createIntent / confirmIntent / captureIntent / cancelIntent / refund / getIntent), header pinning, idempotency-key injection, sandbox URL flip.
|
||||
- `src/mapping/payment.test.ts` — `mapPaymentStatus` exhaustive coverage and `mapPayment` shape mapping including missing-card-details and approved-money branches.
|
||||
- `src/mapping/refund.test.ts` — `mapRefundStatus` exhaustive coverage and `mapRefund` reason fallback.
|
||||
- `src/types/config.test.ts` — type-only assertions pinning `SquareConfig extends ApiKeyConfig` and the `locationId` required-ness.
|
||||
- `tests/dep-graph.test.ts` — universal-tier purity (no `node:` / `bun:` / `fs` / `path` / `crypto` imports in `src/`).
|
||||
|
||||
All tests use real `FetchLike` recorders (closures over a calls array returning real `Response` objects) and a deterministic injected `randomId` — no `vi.mock`, no `globalThis.fetch` patching, no monkey-patching.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. The package is new; no consumer in the genesis monorepo currently imports `SquareProvider`. Upstream consumers were already directed to switch their import path by `2026-05-17-slim-payment-providers` (see that change's `## Migration` section and the foundation `README.md`).
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/payment-provider-square` to enumerate. No dependents at landing time — the package is the first in the `payment-provider-*` sibling family.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/payment-provider-square`)
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1,69 @@
|
||||
# Tasks — add-payment-provider-square
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/payment-providers.md` end-to-end
|
||||
- [x] 1.2 Read upstream `src/square.ts`, `src/square.md`, `src/square.test.ts`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Read slim archive `openspec/archive/2026-05-17-slim-payment-providers/`
|
||||
- [x] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.6 Fill in `## Source mapping` table — every requirement cites a line range or appears in the "no source line" table with justification
|
||||
- [x] 1.7 Draft `specs/payment-provider-square/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.8 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Create `packages/universal/payment-provider-square/` with `package.json` (`0.1.0`, deps: `@suaveplan/payment-providers`, `@suaveplan/payment`, `tslib`), `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `tests/setup/polyfills.ts`
|
||||
- [x] 2.2 `src/types/config.ts` — `SquareConfig extends ApiKeyConfig` with `locationId`, `sandbox?`, `randomId?`, `apiVersion?`; export constants `SQUARE_DEFAULT_BASE_URL`, `SQUARE_SANDBOX_BASE_URL`, `SQUARE_DEFAULT_API_VERSION`
|
||||
- [x] 2.3 `src/mapping/payment.ts` — `mapPaymentStatus`, `mapPayment`, `SquarePaymentDto` type
|
||||
- [x] 2.4 `src/mapping/refund.ts` — `mapRefundStatus`, `mapRefund`, `SquareRefundDto` type
|
||||
- [x] 2.5 `src/square/square.ts` — `SquareProvider implements PaymentProvider` class
|
||||
- [x] 2.6 `src/index.ts` — explicit named re-exports (§6a)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/square/square.test.ts` — full lifecycle, deterministic `randomId`, real `FetchLike` recorder, sandbox URL flip, header pinning, idempotency-key forwarding
|
||||
- [x] 3.2 `src/mapping/payment.test.ts` — every status branch, missing-card-details, approved-money branch
|
||||
- [x] 3.3 `src/mapping/refund.test.ts` — every refund-status branch, reason fallback
|
||||
- [x] 3.4 `src/types/config.test.ts` — type-level assertions (`expectTypeOf`) on `SquareConfig extends ApiKeyConfig` and `locationId` required-ness
|
||||
- [x] 3.5 `tests/dep-graph.test.ts` — universal-tier purity gate (no `node:` / `bun:` / `fs` / `path` / `crypto` imports)
|
||||
- [x] 3.6 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 `src/square/square.md` — sibling doc (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 `src/types/config.md` — sibling doc
|
||||
- [x] 4.3 `src/mapping/payment.md` — sibling doc
|
||||
- [x] 4.4 `src/mapping/refund.md` — sibling doc
|
||||
- [x] 4.5 `README.md` linking every co-located `.md` under `## Modules`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0/0/0 across the touched files (run inside the package)
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/payment-provider-square` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/payment-provider-square` — 100/100/100/100
|
||||
- [x] 5.4 Manual spec-completeness audit (§12a) — every requirement implemented, every `Verified by` test exists with verbatim title
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/payment-provider-square` — green
|
||||
|
||||
## 6. Pre-archive 8-row checklist (skill Step 7)
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` on literal-type claims (`name: "square"`, `SquareConfig extends ApiKeyConfig`)
|
||||
- [x] 6.2 No Zod boundaries claimed by spec — config is a TS interface, no runtime parse boundary in this package
|
||||
- [x] 6.3 No error codes added — the package reuses `PAYMENT_PROVIDER_CODES` from the foundation and raises only `PaymentProviderError` via the foundation's `httpRequest`
|
||||
- [x] 6.4 No strict-mode toggles claimed
|
||||
- [x] 6.5 No OTel instruments declared by this package (foundation owns metrics)
|
||||
- [x] 6.6 Idempotency-key generation tested with a deterministic injected `randomId`
|
||||
- [x] 6.7 No retroactive tick — every box reflects work done in this change
|
||||
- [x] 6.8 No `export *` in any feature-module barrel — every `<feature>/index.ts` uses named re-exports
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports — every test imports from `@suaveplan/testing/runner`
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/payment-provider-square/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-payment-provider-square/` → `openspec/archive/2026-05-17-add-payment-provider-square/`
|
||||
- [x] 7.3 Refresh `STATUS.md` (`Pending` → `Completed` row added)
|
||||
- [x] 7.4 Refresh `SUMMARY.md` (per-package paragraph added)
|
||||
- [x] 7.5 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts` (run after package lands)
|
||||
- [x] 7.6 Add package row to `CLAUDE.md` listing
|
||||
Reference in New Issue
Block a user