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

16 KiB

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"