Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ brew install --cask stuntapi/tap/stunt
```powershell
# clone the manifest repo, then install from the local manifest dir
git clone --depth 1 https://github.com/stuntapi/winget
winget install --manifest winget/manifests/s/StuntAPI/Stunt/0.25.0
winget install --manifest winget/manifests/s/StuntAPI/Stunt/0.31.0
```

> Once stunt is submitted to the default winget source, this becomes simply
Expand Down Expand Up @@ -151,6 +151,11 @@ synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`
Full adapter authoring reference (the `adapter.yaml` schema, **Starlark builtins reference** with
exact signatures, gRPC/WebSocket/GraphQL sections): **[`adapters/README.md`](adapters/README.md)**.

**Fidelity platform** (cross-cutting, in every adapter): real list-filter/query params
(`query_select`), cursor pagination, validated tokens with expiry (401 paths), per-provider
signed webhook delivery (HMAC/ECDSA/Ed25519 schemes), derive-on-read async state machines
(RUNNING/FAILED + failure injection), multipart uploads, byte-exact binary round-trips.

## Transports & primitives

- **Transports**: REST, gRPC (unary **and streaming**), WebSocket, GraphQL (full introspection + DoS limits).
Expand Down
132 changes: 119 additions & 13 deletions adapters/adyen-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,24 @@ far-future expiry; send any other key to exercise the 401 path.
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/v68/payments` | Create a payment (honours `Idempotency-Key`) |
| POST | `/v68/payments/details` | Complete a 3DS2 payment (fingerprint / challengeResult) |
| GET | `/v68/payments?reference=` | Look up a payment by merchant reference |
| GET | `/v68/payments?pageSize=&cursor=` | List payments (cursor-paginated) |
| POST | `/v68/payments/{paymentPspReference}/captures` | Capture a payment |
| POST | `/v68/payments/{paymentPspReference}/refunds` | Refund a payment |
| POST | `/v68/paymentMethods` | List the merchant's available payment methods |
| POST | `/v68/paymentLinks` | Create a hosted payment link |
| GET | `/v68/paymentLinks/{linkId}` | Get a payment link (status derived on read) |
| POST | `/v68/payments/{paymentPspReference}/captures` | Capture a payment (validated against remaining balance) |
| POST | `/v68/payments/{paymentPspReference}/refunds` | Refund a payment (validated against captured balance) |
| POST | `/v68/payments/{paymentPspReference}/reversals` | Reverse a payment |
| POST | `/v68/payments/{paymentPspReference}/cancels` | Cancel a payment |
| POST | `/v68/payments/{paymentPspReference}/cancels` | Cancel an uncaptured payment |
| POST | `/v68/notifications/test` | Receive notification (202 `[accepted]` + HMAC doc) |
| POST | `/v68/webhooks` | Register a webhook subscription (per-hook `hmacKey`) |
| GET | `/v68/webhooks` | List webhook subscriptions |
| DELETE | `/v68/webhooks/{webhookId}` | Delete a webhook subscription |

Modification routes serialize per payment (`concurrency_key`), so concurrent
captures/refunds cannot race the balance checks.

Any unmatched route returns a 404 with an Adyen-shaped error body.

## Deterministic test outcomes
Expand All @@ -87,24 +94,123 @@ The adapter models Adyen's deterministic test card outcomes:

| Card number ending | resultCode | Description |
|--------------------|------------|-------------|
| `4111...` (default) | `Authorised` | Successful authorisation |
| `4111...` (default) | `Authorised` | Successful instant authorisation |
| `...0002` | `Refused` | Generic refusal |
| `...0069` | `Received` | Requires additional action (3DS) |
| `...0069` | `IdentifyShopper` | Native 3DS2: fingerprint → authorised via `/payments/details` |
| `...0081` | `IdentifyShopper` | Native 3DS2 with challenge round: fingerprint → `ChallengeShopper` → challengeResult → authorised |

Any payment also accepts a simulator-only `simulate_fail: true` flag. On an
instant payment it forces `Refused`; inside the 3DS2 flow it makes the final
`/payments/details` result `Refused` with `refusalReason: "threeDSError"`.

## 3DS2 flow (`/payments/details`)

```bash
# 1. Start a payment with a 3DS test card (...0069 / ...0081)
curl -s -X POST http://localhost:8080/v68/payments \
-H "X-API-Key: your-api-key" -H "Content-Type: application/json" \
-d '{
"merchantAccount": "TestMerchant",
"amount": { "value": 1000, "currency": "USD" },
"reference": "order-3ds",
"paymentMethod": { "type": "scheme", "number": "411111...0069" },
"returnUrl": "https://shop.test/return"
}'
# → { "resultCode": "IdentifyShopper",
# "action": { "type": "threeDS2", "subtype": "fingerprint",
# "paymentData": "PD1" } }
# (no pspReference yet — it only exists once the flow reaches a terminal result)

# 2. Submit the fingerprint
curl -s -X POST http://localhost:8080/v68/payments/details \
-H "X-API-Key: your-api-key" -H "Content-Type: application/json" \
-d '{ "paymentData": "PD1", "details": { "threeds2.fingerprint": "..." } }'
# → { "resultCode": "Authorised", "pspReference": "8814...", "additionalData": {...} }
```

Challenge test cards (`...0081`) get an extra round: submitting the
fingerprint returns `ChallengeShopper` with a **new**
`action.paymentData` (subtype `challenge`); submitting
`details.threeds2.challengeResult` against that token returns the final
result. `paymentData` tokens are single-use: each round mints a new token and
clears the previous one, and an unknown/expired token returns `422`
(`errorCode: "100"`, `errorType: "validation"`).

The pending 3DS payment emits no notification; the terminal
`/payments/details` result emits the `AUTHORISATION` notification
(`success: "true"` / `"false"`), exactly like an instant payment.

## Payment lifecycle

```
Authorised → Captured (capture)
Authorised → Refunded (refund)
Authorised → Reversed (reversal)
Authorised → Cancelled (cancel)
Authorised → Captured (capture, full)
Authorised → PartiallyCaptured (partial capture; remainder still capturable)
PartiallyCaptured → Captured (further captures)
Captured/PartiallyCaptured → PartiallyRefunded/Refunded (refunds)
Authorised → Cancelled (cancel)
Authorised/Captured → Reversed (reversal)
Pending3DS → Authorised/Refused (via /payments/details)
```

Modifications are **validated** like the real API; violations return `422`
with the Adyen error envelope and `errorType: "modification"`:

| Rule | Failure |
|------|---------|
| Capture amount ≤ authorisedAmount − already captured | `702` capture amount exceeds the remaining authorised amount |
| Capture only from `Authorised`/`PartiallyCaptured` | `701` payment is not in a capturable state |
| Refund amount ≤ capturedAmount − already refunded | `705` refund amount exceeds the captured amount |
| Refund only after capture | `704` payment has not been captured |
| Cancel only an uncaptured (`Authorised`) payment | `706` only an authorised payment can be cancelled |
| Modification currency must match the payment | `708` currency does not match |
| Amount must be > 0 | `703` invalid amount |

Omitting `amount` captures (or refunds) the **full remaining** balance.
Modification routes carry a `concurrency_key`, so concurrent requests against
the same payment are serialized and the balance checks cannot race.

Each successful authorisation emits an `AUTHORISATION` event (refusals emit
`AUTHORISATION` with `success: "false"`; `Received` / 3DS-pending does not
notify), and each modification emits `CAPTURE`, `REFUND`, `REVERSAL` or
`CANCEL_OR_REFUND`. Deliveries go to webhooks registered via
`POST /v68/webhooks` (see below).
`AUTHORISATION` with `success: "false"`; 3DS-pending payments notify when
`/payments/details` reaches a terminal result), and each modification emits
`CAPTURE`, `REFUND`, `REVERSAL` or `CANCEL_OR_REFUND`. Deliveries go to
webhooks registered via `POST /v68/webhooks` (see below).

## Payment methods

`POST /v68/paymentMethods` (body: `{"merchantAccount": "..."}`) returns the
fixed simulator catalogue — `scheme` (card), `ideal`, `paypal`, `applepay`,
`googlepay` — plus an empty `storedPaymentMethods` list. A missing
`merchantAccount` returns `422`.

## Payment links

```bash
curl -s -X POST http://localhost:8080/v68/paymentLinks \
-H "X-API-Key: your-api-key" -H "Content-Type: application/json" \
-d '{
"reference": "order-001",
"amount": { "value": 2500, "currency": "USD" },
"merchantAccount": "TestMerchant",
"expiresAt": "2030-01-01T00:00:00Z"
}'
# → { "id": "PL1", "url": "https://checkout.stunt.local/pay/PL1",
# "status": "active", "expiresAt": "...", "reference": "order-001", ... }
```

Lifecycle (derived on `GET /v68/paymentLinks/{linkId}`, transitions persisted
once):

```
active → completed a payment created with the link's reference was authorised
active → expired the link's expiresAt is past
```

- `expiresAt` is optional (default: 24 h) and must be RFC 3339 in UTC `Z`
format when supplied, so expiry can be compared lexicographically.
- A missing/zero `amount.value` or missing `reference` returns `422`.
- An unknown link id returns `404` (`errorCode: "191"`).
- `url` is a synthetic stable identifier — the simulator hosts no checkout
page; drive the flow by paying against the link's `reference`.

## Webhooks

Expand Down
26 changes: 26 additions & 0 deletions adapters/adyen-style/adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,47 @@ endpoints:
- route: /v68/payments
method: POST
handler: scripts/payments.star#on_create_payment
# Literal /payments/details must precede the parametrised modification
# routes below.
- route: /v68/payments/details
method: POST
handler: scripts/payments.star#on_payment_details
- route: /v68/payments
method: GET
handler: scripts/payments.star#on_list_payments

# --- Payment methods (Checkout) ---
- route: /v68/paymentMethods
method: POST
handler: scripts/payment_methods.star#on_payment_methods

# --- Payment links (Checkout) ---
- route: /v68/paymentLinks
method: POST
handler: scripts/payment_links.star#on_create_payment_link
- route: /v68/paymentLinks/{linkId}
method: GET
handler: scripts/payment_links.star#on_get_payment_link

# --- Payment lifecycle (modifications) ---
# concurrency_key serialises read-modify-write handlers against the same
# payment (balance checks + state transitions).
- route: /v68/payments/{paymentPspReference}/captures
method: POST
handler: scripts/payments.star#on_capture
concurrency_key: paymentPspReference
- route: /v68/payments/{paymentPspReference}/refunds
method: POST
handler: scripts/payments.star#on_refund
concurrency_key: paymentPspReference
- route: /v68/payments/{paymentPspReference}/reversals
method: POST
handler: scripts/payments.star#on_reversal
concurrency_key: paymentPspReference
- route: /v68/payments/{paymentPspReference}/cancels
method: POST
handler: scripts/payments.star#on_cancel
concurrency_key: paymentPspReference

# --- Notifications (Adyen sends HMAC-signed notifications to merchant) ---
# This endpoint simulates the merchant-side notification receiver that
Expand All @@ -61,6 +85,8 @@ resources:
kind: collection
- name: modifications
kind: collection
- name: payment_links
kind: collection
- name: webhooks
kind: collection

Expand Down
124 changes: 97 additions & 27 deletions adapters/adyen-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -70,31 +70,41 @@ def _mod_psp_reference(prefix):
n = store_kv_incr("adyen", "mod_seq")
return prefix + str(8000000000000 + n)

# _determine_result_code models Adyen's deterministic test outcomes.
#
# Adyen test card numbers:
# 4111... → Authorised
# 4000...0002 → Refused (generic refused)
# 4000...0069 → Received (authorized but requires additional action)
#
# We keep it simple and deterministic for testing.
def _determine_result_code(payment_method):
number = ""
if payment_method != None:
number = payment_method.get("number", "")
if number == None:
number = ""
# _payment_flow models Adyen's deterministic test outcomes as a flow, not just
# a result code:
# "refused" – test card ending 0002 refuses outright
# "threeds" – test cards ending 0069 / 0081 start the native 3DS2 flow:
# the /payments response is IdentifyShopper with
# action.type=threeDS2 + action.paymentData; completion goes
# through POST /payments/details (see payments.star)
# "auth" – everything else authorises instantly
def _payment_flow(payment_method):
number = _card_number(payment_method)

# Refused test card.
if _ends_with(number, "0002"):
return "Refused"
return "refused"

# Received (requires action).
if _ends_with(number, "0069"):
return "Received"
# 3DS2 test cards.
if _ends_with(number, "0069") or _ends_with(number, "0081"):
return "threeds"

# Default: Authorised.
return "Authorised"
# Default: instant authorisation.
return "auth"

# _threeds_challenge reports whether this 3DS test card requires a challenge
# round after the fingerprint (Adyen's ...0081-style challenge test cards).
def _threeds_challenge(payment_method):
return _ends_with(_card_number(payment_method), "0081")

# _card_number safely extracts paymentMethod.number as a string.
def _card_number(payment_method):
number = ""
if payment_method != None:
number = payment_method.get("number", "")
if number == None:
number = ""
return number

# _ends_with checks if str s ends with suffix.
def _ends_with(s, suffix):
Expand All @@ -109,27 +119,87 @@ def _card_summary(number):
return number[-4:]

# _payment_public returns the Adyen-shaped payment response.
# A payment awaiting 3DS2 completion (lifecycle Pending3DS) returns the
# pending resultCode + action (type threeDS2, subtype fingerprint/challenge,
# paymentData) and NO pspReference — the pspReference only exists once the
# flow reaches a terminal result, matching the real Checkout API.
def _payment_public(doc):
result_code = doc.get("resultCode", "Authorised")
pending = doc.get("lifecycle", "") == "Pending3DS"

result = {
"pspReference": doc["id"],
"resultCode": result_code,
"additionalData": doc.get("additionalData", {}),
}

if not pending:
result["pspReference"] = doc["id"]

# For Refused, include refusalReason.
if result_code == "Refused":
result["refusalReason"] = doc.get("refusalReason", "Refused")

# For Received, include action.
if result_code == "Received":
result["action"] = doc.get("action", {
"type": "threeDS2",
"paymentData": doc["id"],
})
# For pending 3DS, include the action object.
if pending:
action = doc.get("action", None)
if action == None:
action = {
"type": "threeDS2",
"subtype": "fingerprint",
"paymentData": doc.get("paymentData", ""),
}
result["action"] = action

return result

# --- paymentData tokens (3DS2 flow) ---
# POST /payments/details carries the opaque paymentData token minted by the
# action object. Tokens are stored in the KV namespace ("pd_<token>" →
# payment pspReference) and cleared once the flow reaches a terminal result.

def _new_payment_data(psp):
token = "PD" + str(store_kv_incr("adyen", "pd_seq"))
store_kv_set("adyen", "pd_" + token, psp)
return token

def _payment_data_psp(token):
if token == None or token == "":
return None
v = store_kv_get("adyen", "pd_" + token)
if v == None or v == "":
return None
return v

def _clear_payment_data(token):
if token != None and token != "":
store_kv_set("adyen", "pd_" + token, "")

# --- amount coercion helpers ---
# JSON round-trips decode integers as Starlark floats; amounts live in both
# int and float form depending on where they came from.

def _num(v):
if v == None:
return 0.0
if type(v) == "int":
return float(v)
if type(v) == "float":
return v
return float(_to_int(v))

def _amt_value(amount):
if amount == None:
return 0.0
return _num(amount.get("value", 0))

def _amt_currency(amount):
if amount == None:
return ""
cur = amount.get("currency", "")
if cur == None:
return ""
return cur

# _modification_public returns the Adyen-shaped modification response.
# Shape: { pspReference, status:"received", paymentPspReference }
def _modification_public(mod_psp, payment_psp):
Expand Down
Loading
Loading