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. (Seeopenspec/AGENTS.mdSection 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
SquareProvideris constructed with any validSquareConfig - THEN
provider.name === "square"and the static type ofprovider.nameis the literal"square"(assertable withexpectTypeOf)
Scenario: assignable to PaymentProvider
- WHEN a
SquareProviderinstance is assigned to aPaymentProvider-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
SquareConfigwithoutlocationId - THEN the TypeScript compiler rejects the construction (assertable with
expectTypeOf<...>().toMatchTypeOf<...>())
Scenario: sandbox flag flips the default base URL
- WHEN a
SquareProvideris constructed withsandbox: trueand no explicitbaseUrl, and any method is invoked with a recordingFetchLike - THEN the recorded request URL starts with
https://connect.squareupsandbox.com/v2
Scenario: explicit baseUrl wins over sandbox flag
- WHEN a
SquareProvideris constructed withsandbox: trueANDbaseUrl: "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
Authorizationheader equals"Bearer <apiKey>"
Scenario: Square-Version header pins the API version
- WHEN a
SquareProvideris constructed withapiVersion: "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
SquareProvideris constructed without anapiVersionand 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.bodyequalsJSON.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
createIntentis invoked withcaptureMethod: "manual" - THEN the recorded
init.bodyincludes"autocomplete":false
Scenario: missing idempotencyKey is filled from injected randomId
- WHEN
createIntentis invoked withoutidempotencyKeyon a provider constructed withrandomId: () => "deterministic-id" - THEN the recorded
init.bodyincludes"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 hasstatus: "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 hasstatus: "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.bodyequalsJSON.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 withrandomId: () => "ref-gen" - THEN the recorded
init.bodyequalsJSON.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
mapPaymentStatusis invoked with each ofAPPROVED,COMPLETED,CANCELED,FAILED,PENDING - THEN the return values are
authorized,captured,canceled,failed,pendingrespectively
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), andpaymentMethod: { 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"andcapturedAmount.amount: 0n
Scenario: payment without card details has undefined paymentMethod
- WHEN
mapPayment({ ..., card_details: undefined })is invoked - THEN the result's
paymentMethodisundefined
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
mapRefundStatusis invoked withCOMPLETED,PENDING,REJECTED,FAILED - THEN the return values are
succeeded,pending,failed,failedrespectively
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 returns404with body{ "message": "Not Found" } - THEN the awaited promise rejects with a
PaymentProviderErrorwhosecode === PAYMENT_PROVIDER_CODES.HTTP_ERROR,httpStatus === 404, andprovider === "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.tssweep walks every.tsundersrc/ - 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"