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
80 changes: 44 additions & 36 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,21 @@ typed `payments()` endpoints, request/response DTOs, non-exhaustive `PaymentStat
a `QuickpayException` hierarchy, and a timing-safe `CallbackValidator`. Basic auth, the mandatory
`Accept-Version: v10` header and host pinning to `api.quickpay.net` all live in the SDK. The SDK went
stable on 2026-08-10, so the constraint is a plain caret and **consumers need no `minimum-stability`
setting at all** — that requirement, and the docs describing it, are gone. The constraint is **`^1.1`**
(2026-08-17): 1.1 is additive but the gateway uses what it added — `Operation::isApproved()` (not
pending AND `20000`; every `Operations` helper builds on it) and `isOfType()`, `Shopsystem` on the
create request (`ConvertPaymentAction` identifies the integration as `setono/payum-quickpay` +
installed version via Composer's runtime API), and `PaymentsEndpoint::findByOrderId()` for the
find-or-create in `Convert`. Not used on purpose: the SDK's `Payment::latestOperation()` /
`hasPendingOperation()` are type-agnostic and its amount helpers sum — the actions need the per-type,
last-approved views `Operations` provides. `cache:` on the SDK `Client` is reachable through the
`quickpay.client` option (build the client with it) rather than a gateway option of its own, so the
package does not have to depend on Valinor's cache interface. Since 1.1 an empty body (cancel) is sent
as `{}`, not as nothing.
setting at all** — that requirement, and the docs describing it, are gone. The constraint is **`^1.2`**
(2026-08-17): 1.1/1.2 are additive but the gateway uses what they added, and both were shaped by this
gateway's review (quickpay-php-sdk#22, #25) — `Operation::isApproved()` (not pending AND `20000`),
`isOfType()`, `hasOutcome()`/`isDeclined()`; the `Payment` views `latestOperationOfType()`,
`latestApprovedOperation()`, `hasApprovedOperation(?type)`, `hasPendingOperation(?type)` ("latest" =
highest operation id — the test fixtures number operations for that reason); `Link::$autoCapture`;
`callbackUrl:` on `capture()/refund()/cancel()` (the `QuickPay-Callback-Url` header); `Shopsystem` on
the create request (`ConvertPaymentAction` identifies the integration as `setono/payum-quickpay` +
installed version via Composer's runtime API); `PaymentsEndpoint::findByOrderId()` for the
find-or-create in `Convert`; and `CreatePaymentRequest::ORDER_ID_PATTERN` (4–20 of `[A-Za-z0-9 ._-]`,
which `Convert` checks first with a message naming the prefix and number). The package's own
`Operations` helper is gone — everything reads off the DTOs. `cache:` on the SDK `Client` is reachable
through the `quickpay.client` option (build the client with it) rather than a gateway option of its
own, so the package does not have to depend on Valinor's cache interface. Since 1.1 an empty body
(cancel) is sent as `{}`, not as nothing.

The pre-release history is worth remembering only because it explains why the constraint used to be so
specific: the gateway cannot run on alpha.1 (no client-wide `synchronized`) and breaks on alpha.1–2
Expand Down Expand Up @@ -149,8 +153,8 @@ Request → Action flow (amounts are integer minor units everywhere — no conve
gateway option (an `Authorize` that behaves as a sale — prefer executing `Capture`). A pending
authorize (3-D Secure, async acquirer) is a no-op for both. `AuthorizeAction` on an approved
authorize (or any capture) is a no-op — the return trip. **`CaptureAction` on an approved authorize
never captures when the payment's link carries `auto_capture`** (`raw['link']['auto_capture']` — the
SDK's `Link` DTO does not model it): Quickpay is taking that money, and a capture from here could only
never captures when the payment's link carries `auto_capture`** (`$payment->link?->autoCapture`, typed
since SDK 1.2): Quickpay is taking that money, and a capture from here could only
double up — **except when Quickpay's own capture was declined** (`quickpayGaveUpCapturing()`: no
capture approved or pending, the newest capture a completed decline; Quickpay attempts it once and
does not retry). Then a *token-less* `Capture` (the merchant settling — the shop's code, a
Expand All @@ -173,16 +177,19 @@ Request → Action flow (amounts are integer minor units everywhere — no conve
"Transaction in wrong state for this operation" to "Validation error: Payment is not in a valid state
for cancel", so the guard silently never fired) and wrong in substance: reporting success for a
payment whose money is still held. A caller wanting a no-op can catch the typed exception itself.
- **Notify** → `NotifyAction` — entry point for Quickpay's server-to-server callback. **Quickpay routes
callbacks to two different urls** (verified live, 2026-08): the payment-window authorize goes to the
per-payment `callback_url` on the link — the notify token `CreatePaymentLinkAction` mints on behalf of
`Capture`/`Authorize` — while API-initiated
`capture`/`refund`/`cancel` go to the **account-wide** url (manager → Settings → Integration), which is
empty by default, so those callbacks are simply not delivered. That url is static for every payment and
cannot carry a `payum_token`, so Payum's token routing cannot serve it; an endpoint for it must resolve
the payment from the body's `order_id` (`order_prefix` + payum number) and execute `Notify` against
that model — `NotifyAction` needs only the model. See `examples/e2e/listen.php` and
`docs/UPGRADE-2.0.md`. It fetches the raw
- **Notify** → `NotifyAction` — entry point for Quickpay's server-to-server callback. **Where callbacks
go** (verified live, 2026-08): the payment window's goes to the per-payment `callback_url` on the link
— the notify token `CreatePaymentLinkAction` mints on behalf of `Capture`/`Authorize`; an API-issued
`capture`/`refund`/`cancel` would go to the **account-wide** url (manager → Settings → Integration,
empty by default) — so `CaptureAction`/`RefundAction`/`CancelAction` name the payment's own notify url
(`Details::callbackUrl()`, i.e. the details' `callback_url`) on every operation via the SDK's
`callbackUrl:` (`QuickPay-Callback-Url` header), and those callbacks arrive on the same token endpoint
(verified live: delivered within a second, `callback_success: true` on the operation). Only operations
made outside the gateway (manager, raw API) still hit the account-wide url; that url is static for
every payment and cannot carry a `payum_token`, so an endpoint for it must resolve the payment from
the body's `order_id` (`order_prefix` + payum number) and execute `Notify` against that model —
`NotifyAction` needs only the model. See `examples/e2e/listen.php` and `docs/UPGRADE-2.0.md`. It
fetches the raw
body + `QuickPay-Checksum-Sha256` header (via Payum's `GetHttpRequest`), **verifies the HMAC signature**
with the SDK `CallbackValidator`, and rejects an invalid/unsigned callback with a 400 `HttpResponse`
before delegating to the internal `ConfirmPayment` request.
Expand Down Expand Up @@ -246,17 +253,17 @@ actions need (`getOrderPrefix()`, `getPaymentMethods()`, `getLanguage()`, `isAut
`isSynchronized()` is not a state of its own — it reads through to the wrapped client, so there is one
source of truth for the flag.

### Operations helper (`src/Operations.php`)
### Reading a payment (no helper class of our own)

The custom `src/Model/*` classes are gone — responses are the SDK's readonly DTOs (`Response\Payment\
{Payment,Operation,Link}`) plus the `PaymentState`/`OperationType` enums. The behavior that used to live
on those models now lives in the stateless `Operations` helper over a `list<Operation>`:
`isApproved()` (status code `20000`), `isApprovedOfType()`, `latestApproved()`, `latestOfType()`,
`hasApproved()`, `hasPending()`. `StatusAction` (last *approved* operation, so a trailing
rejected/pending attempt does not mask what happened), the `Authorize`/`Capture` entry-point decisions
and the declined-operation check are driven by these helpers plus the SDK enums. Every helper has a
caller in `src/`; the ones that lost theirs along the way (`latest()`, `isLatestApproved()`,
`authorizedAmount()`) were dropped before 2.0 rather than kept as accidental public API.
{Payment,Operation,Link}`) plus the `PaymentState`/`OperationType` enums — and so is the package's
interim `Operations` helper: since SDK 1.2 the DTOs answer the questions the actions ask.
`StatusAction` decides from `Payment::latestApprovedOperation()` (so a trailing rejected/pending
attempt does not mask what happened to the money), the `Authorize`/`Capture` entry points from
`hasApprovedOperation(type)`/`hasPendingOperation(type)`, the declined-operation check from
`latestOperationOfType(type)?->isDeclined()`, and `Convert`'s adopt-or-refuse from
`latestApprovedOperation()`. "Latest" is the highest operation id (Quickpay numbers them per payment
from 1); the test fixtures number their operations for exactly that reason.

## End-to-end harness (`examples/e2e/`)

Expand All @@ -273,10 +280,11 @@ Two things it encodes that are easy to get wrong:
`addCoreGatewayFactoryConfig(['payum.action.get_http_request' => ...])` — payum/core's plain-PHP
bridge does **not** populate `GetHttpRequest::$headers`, so outside Symfony every callback would be
rejected as unsigned. The harness is the reference for that wiring.
- `listen.php` handles **both** callback shapes: the payment-window one carries a `payum_token`, while a
callback sent to the account-wide url cannot, so it resolves the payment from the body's `order_id`
instead. The log tags each `via=token` or `via=order_id`, which is how the two-url routing above was
established in the first place.
- `listen.php` handles **both** callback shapes: everything the gateway is party to — the payment
window's callback and, since the operations name the notify url, the capture/refund/cancel ones —
carries a `payum_token`; a callback sent to the account-wide url (an operation made in the manager)
cannot, so it resolves the payment from the body's `order_id` instead. The log tags each `via=token` or
`via=order_id`, which is how the two-url routing was established in the first place.

`e2e:operate` takes an optional amount on `capture` and `refund`, setting the `capture_amount` /
`refund_amount` details keys.
Expand Down
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,18 @@ tampered callback is rejected with a `400`. Quickpay retries an undelivered or n
your logs rather than as silence — and a callback your endpoint fails on will come back. Two things
are easy to get wrong:

**Quickpay sends callbacks to two different places.** The payment-window authorize goes to the
per-payment callback url the gateway builds (a Payum notify token). But `capture`/`refund`/`cancel`
issued through the API go to the **account-wide** callback url (Quickpay manager → Settings →
Integration) — which is empty by default, so those callbacks are simply not delivered anywhere, and
a shop waiting to hear that its capture settled waits forever. That url is one static url for every
payment and cannot carry a `payum_token`, so an endpoint for it must resolve the payment from the
callback body's `order_id` (`order_prefix` + the Payum payment number) and execute `Notify` against
that model. See [`docs/UPGRADE-2.0.md`](docs/UPGRADE-2.0.md) for the full picture and
`examples/e2e/listen.php` for a working endpoint. Alternatively, skip operation callbacks entirely:
set `synchronized` to `true` so the operations block until settled, or poll with `Sync`/`GetStatus`.
**Where callbacks go.** The payment window's callback goes to the per-payment `callback_url` on the
link — the Payum notify token the gateway mints — and Payum routes it to your payment. Quickpay would
send the callback of an *API-issued* `capture`/`refund`/`cancel` somewhere else: to the **account-wide**
callback url (Quickpay manager → Settings → Integration), which is empty by default — so the gateway
names the payment's own notify url on every capture, refund and cancel it issues (the
`QuickPay-Callback-Url` header), and those callbacks arrive on the **same per-payment endpoint**,
verified and routed like the payment window's. Nothing to configure. Only operations issued *outside*
the gateway — in the Quickpay manager, or your own API calls — still go to the account-wide url; if you
want to hear about those, set it and give it an endpoint that resolves the payment from the callback
body's `order_id` (`order_prefix` + the Payum payment number) and executes `Notify` against that model
(`examples/e2e/listen.php` does exactly this). Alternatively, `synchronized` makes the operations block
until settled — a decline then throws (see above) — or poll with `Sync`/`GetStatus`.

**A declined operation is not an HTTP error.** Quickpay answers `2xx` and puts the outcome on the
operation, so the SDK does not throw for it. Asynchronously (the default) the response only says the
Expand Down Expand Up @@ -214,7 +216,7 @@ uses. `quickpayPaymentId` is the single source of truth — everything else is a
| `order_id` | `Convert` | `order_prefix` + the Payum payment number. |
| `continue_url` | `Convert` | The token's **target** url: the customer returns to it, and the `Capture`/`Authorize` that sent them out runs again to finish. |
| `cancel_url` | `Convert` | The token's after url. |
| `callback_url` | `Capture`, `Authorize` | The notify token url given to Quickpay. |
| `callback_url` | `Capture`, `Authorize` | The payment's notify token url — on the link, and named on every capture/refund/cancel the gateway issues so their callbacks arrive there too. |
| `balance` | every action that fetches the payment: `Capture`, `Authorize`, `GetStatus`, `Sync`, `Notify`, `Refund` (default path) | **What is still captured** — captured minus refunded. |
| `state` | `Sync`, `Notify` | Quickpay's own payment state. |
| `capture_amount`, `refund_amount` | *you* | Optional partial-operation amounts; see below. |
Expand Down Expand Up @@ -336,9 +338,10 @@ want to **authorize only** at checkout and capture later — when the order ship
`use_authorize: true` in the gateway configuration so Sylius executes `Authorize` instead, and issue
the `Capture` yourself when the time comes.

The callback urls (see Callbacks above) apply unchanged: the payment-window callback routes itself
via the notify token, and an account-wide callback endpoint — if you rely on operation
confirmations — needs to resolve the payment by `order_id` and execute `Notify` on it.
The callbacks (see above) route themselves via the notify token — the payment window's and the ones
for captures, refunds and cancels the gateway issues alike. An account-wide callback endpoint that
resolves the payment by `order_id` (the plugin ships one) is only needed for operations made outside
the gateway, e.g. in the Quickpay manager.

## What this package does not do

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"composer-runtime-api": "^2.0",
"payum/core": "^1.7.5",
"php-http/message-factory": "^1.0",
"setono/quickpay-php-sdk": "^1.1"
"setono/quickpay-php-sdk": "^1.2"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
Expand Down
Loading
Loading