Skip to content

Add Payzum as a crypto/stablecoin payment provider (async settlement via signed notification) - #4713

Open
payzum-hq wants to merge 2 commits into
LycheeOrg:masterfrom
payzum-hq:add-payzum-gateway
Open

Add Payzum as a crypto/stablecoin payment provider (async settlement via signed notification)#4713
payzum-hq wants to merge 2 commits into
LycheeOrg:masterfrom
payzum-hq:add-payzum-gateway

Conversation

@payzum-hq

@payzum-hq payzum-hq commented Sep 3, 2026

Copy link
Copy Markdown

Implements the plan agreed in #4712 (option 1, the inbound notification endpoint @ildyria preferred). Adds Payzum, a non-custodial crypto/stablecoin gateway (USDC, USDT and more, multi-chain — funds settle directly to the photographer's own wallet), as an Omnipay provider.

Since crypto confirmation is asynchronous, the interesting part is settlement — following the pattern discussed in the issue:

  • New notification endpoint POST /api/v2/Shop/Checkout/Notify/Payzum/{order_id} (shop.checkout.notify; keyed by order id, because completing an order replaces its transaction id with the provider reference and the gateway retries to the original URL): the Omnipay driver verifies the HMAC-SHA-512 signature over the raw request bytes (with a replay window) before any payload field is readable. After signature, order, amount and currency checks, the order is completed idempotently — a redelivered notification can never fulfil twice. OrderCompleted is dispatched here (guarded by wasChanged('status')) because the buyer may never come back to the browser.
  • handlePaymentReturn no longer fails confirming payments: for Payzum the return handler polls the invoice once (fetchTransaction) and either completes, fails (expired/cancelled), or leaves the order in PROCESSING — never FAILED just because the buyer returned before the chain confirmed. finalize then redirects to the checkout status page, where a new processing state in CancelledFailed.vue (v7+v8, i18n key added to all locales with the English fallback) tells the buyer the payment is being confirmed and the page can be closed safely.
  • Config/enum: PAYZUM case in OmnipayProviderType (requires apiKey + webhookSecret), config/omnipay.php block, .env.example entries. composer.json/lock gain payzum/omnipay-payzum (MIT) and its only dependency, the official payzum/payzum-php SDK (zero runtime deps); nothing else in the lock changes.
  • FinalizeRequest additionally authorizes COMPLETED Payzum orders, because the notification can legitimately complete the order before the buyer's browser returns.

Tests: CheckoutNotifyControllerTest covers the happy path, forged signatures, pending statuses, expiry, amount mismatches, redelivery idempotency, unknown orders, and the finalize-keeps-processing behaviour. The whole tests/Webshop suite passes (295 tests), as do phpstan and php-cs-fixer.

Disclosure: I work on Payzum and will maintain the integration long-term.

Summary by CodeRabbit

  • New Features

    • Added Payzum as a payment provider.
    • Added asynchronous payment confirmation through secure payment notifications.
    • Orders can remain pending while payment confirmation is in progress and complete automatically afterward.
    • Added localized payment-processing status messages across supported storefront languages.
  • Bug Fixes

    • Prevented duplicate order completion during asynchronous payment updates.
    • Added validation for payment signatures, order details, amounts, and currencies.
    • Improved browser-return handling for payments completed or still being confirmed.

Adds Payzum through Omnipay, with the asynchronous settlement design agreed
in LycheeOrg#4712: a signed inbound notification endpoint completes the order, and the
buyer's return no longer fails a payment that is still confirming on-chain.

- POST /api/v2/Shop/Checkout/Notify/Payzum/{order_id}: the driver verifies
  the HMAC-SHA-512 signature over the raw request bytes before any field is
  readable; order, amount and currency are checked and completion is
  idempotent across redeliveries.
- handlePaymentReturn refreshes the invoice once for Payzum and leaves the
  order in PROCESSING while it confirms; the checkout page renders a new
  'processing' state.
- Config, enum, .env.example and composer entries for payzum/omnipay-payzum.
@payzum-hq
payzum-hq requested a review from a team as a code owner September 3, 2026 08:05
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 134405b2-1cfe-451e-a296-f71b11070e97

📥 Commits

Reviewing files that changed from the base of the PR and between 0c4c369 and 5a8e46d.

📒 Files selected for processing (27)
  • app/Actions/Shop/CheckoutService.php
  • lang/ar/webshop.php
  • lang/bg/webshop.php
  • lang/cz/webshop.php
  • lang/de/webshop.php
  • lang/el/webshop.php
  • lang/en/webshop.php
  • lang/es/webshop.php
  • lang/fa/webshop.php
  • lang/fr/webshop.php
  • lang/hu/webshop.php
  • lang/it/webshop.php
  • lang/ja/webshop.php
  • lang/nl/webshop.php
  • lang/no/webshop.php
  • lang/pl/webshop.php
  • lang/pt/webshop.php
  • lang/ru/webshop.php
  • lang/sk/webshop.php
  • lang/sv/webshop.php
  • lang/tr/webshop.php
  • lang/vi/webshop.php
  • lang/zh_CN/webshop.php
  • lang/zh_TW/webshop.php
  • resources/js/v7/components/webshop/CancelledFailed.vue
  • resources/js/v8/components/webshop/CancelledFailed.vue
  • tests/Webshop/Checkout/CheckoutNotifyControllerTest.php
🚧 Files skipped from review as they are similar to previous changes (7)
  • lang/vi/webshop.php
  • lang/ar/webshop.php
  • lang/sk/webshop.php
  • resources/js/v7/components/webshop/CancelledFailed.vue
  • resources/js/v8/components/webshop/CancelledFailed.vue
  • lang/it/webshop.php
  • app/Actions/Shop/CheckoutService.php

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Payzum support adds provider configuration, asynchronous checkout handling, signed notification processing, pending-payment views, localized messages, and endpoint tests.

Changes

Payzum provider setup

Layer / File(s) Summary
Provider registration and gateway configuration
.env.example, composer.json, config/omnipay.php, app/Enum/OmnipayProviderType.php, app/Factories/OmnipayFactory.php
Adds Payzum credentials, dependency support, provider requirements, and request-bound gateway creation.

Asynchronous checkout returns

Layer / File(s) Summary
Payment status refresh and order transitions
app/Actions/Shop/CheckoutService.php, app/Http/Controllers/Shop/CheckoutController.php, app/Http/Requests/Checkout/FinalizeRequest.php
Stores Payzum transaction references, handles pending returns, updates completed or failed orders, and prevents duplicate completion events.

Signed Payzum notifications

Layer / File(s) Summary
Notification validation and processing
app/Http/Requests/Checkout/NotifyRequest.php, app/Http/Controllers/Shop/CheckoutController.php, app/Actions/Shop/CheckoutService.php, routes/api_v2_shop.php
Adds the Payzum notification route and validates signatures, order data, amounts, currencies, statuses, and duplicate deliveries.

Processing views and coverage

Layer / File(s) Summary
Pending-payment display and tests
resources/js/v7/components/webshop/CancelledFailed.vue, resources/js/v8/components/webshop/CancelledFailed.vue, lang/*/webshop.php, tests/Webshop/Checkout/CheckoutNotifyControllerTest.php
Adds processing-state views and translations. Tests cover completion, rejection, pending, expiry, idempotency, unknown orders, and pending finalization.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 5a8e4

Payzum adds asynchronous crypto-payment settlement with signed notifications and a processing state. The reviewed coverage confirms completed payments remain reachable by browser returns and are fulfilled only once, with no remaining merge-blocking risk.

Poem

A rabbit checks the payment trail,
While Payzum sends a signed detail.
Pending orders wait in line,
Then hop to complete when all is fine.
The checkout page glows soft and bright,
“Safe to close”—goodnight, goodnight!

🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 32 files. (2 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Full details: Docstring Coverage

Explanation

Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 32 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcomposer/​payzum/​omnipay-payzum@​0.1.1.010010090100100

View full report

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
lang/ar/webshop.php (1)

288-289: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use snake_case for the new translation keys.

Rename paymentProcessing and paymentProcessingMessage to payment_processing and payment_processing_message in all changed locale files. Update both the v7 and v8 Vue consumers to use the renamed keys.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 3e77f143-0002-49fd-b0b9-f2fcff57aaf8

📥 Commits

Reviewing files that changed from the base of the PR and between 4a8bbe5 and 0c4c369.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (36)
  • .env.example
  • app/Actions/Shop/CheckoutService.php
  • app/Enum/OmnipayProviderType.php
  • app/Factories/OmnipayFactory.php
  • app/Http/Controllers/Shop/CheckoutController.php
  • app/Http/Requests/Checkout/FinalizeRequest.php
  • app/Http/Requests/Checkout/NotifyRequest.php
  • composer.json
  • config/omnipay.php
  • lang/ar/webshop.php
  • lang/bg/webshop.php
  • lang/cz/webshop.php
  • lang/de/webshop.php
  • lang/el/webshop.php
  • lang/en/webshop.php
  • lang/es/webshop.php
  • lang/fa/webshop.php
  • lang/fr/webshop.php
  • lang/hu/webshop.php
  • lang/it/webshop.php
  • lang/ja/webshop.php
  • lang/nl/webshop.php
  • lang/no/webshop.php
  • lang/pl/webshop.php
  • lang/pt/webshop.php
  • lang/ru/webshop.php
  • lang/sk/webshop.php
  • lang/sv/webshop.php
  • lang/tr/webshop.php
  • lang/vi/webshop.php
  • lang/zh_CN/webshop.php
  • lang/zh_TW/webshop.php
  • resources/js/v7/components/webshop/CancelledFailed.vue
  • resources/js/v8/components/webshop/CancelledFailed.vue
  • routes/api_v2_shop.php
  • tests/Webshop/Checkout/CheckoutNotifyControllerTest.php

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread app/Actions/Shop/CheckoutService.php Outdated
Comment thread app/Http/Requests/Checkout/FinalizeRequest.php
Comment thread tests/Webshop/Checkout/CheckoutNotifyControllerTest.php
…table

Addresses the review on LycheeOrg#4713:

- Settlement now happens inside a transaction that locks and re-reads the
  order, so the browser return and an inbound notification racing each other
  can no longer both transition it and dispatch OrderCompleted twice. The
  loser adopts the fresh state and reports that it changed nothing.
- Completing an order no longer replaces its transaction id with the gateway
  reference: that id is the lookup key of both the return and the
  notification URLs, so replacing it made the buyer's browser return fail to
  resolve whenever the notification arrived first. The Payzum invoice is
  still reachable by it, as their API reads an invoice by payment id or by
  order id.
- Regression tests: exactly one OrderCompleted across redeliveries, the
  browser return still resolving (and not re-fulfilling) after an early
  notification.
- Translation keys renamed to snake_case in every locale and both Vue
  consumers.
@payzum-hq

Copy link
Copy Markdown
Author

Thanks — all three were worth fixing, and two of them were real defects. Pushed in 5a8e46d.

Atomic settlement. Settlement now runs inside a transaction that locks and re-reads the order (CheckoutService::settle()), so the browser return and an inbound notification racing each other can no longer both transition it. Exactly one caller performs the transition and reports it; the loser adopts the fresh state and reports that it changed nothing, so OrderCompleted is dispatched once. completePayment() routes through it too, which means the other providers get the same guarantee.

Stable lookup key. You were right, and this was the more serious one: completing the order replaced transaction_id with the gateway reference, so once a notification arrived before the buyer's browser, their return URL — built with the original id — no longer resolved and they landed on a "not found" after paying successfully. Payments are now settled with the order's own transaction id, which stays the lookup key of both the return and the notification URLs for the order's whole life. Nothing is lost by this: Payzum's API reads an invoice by payment id or by order id, so the invoice remains reachable from the stored value. Added the regression test you asked for (notification first, then the browser return), plus one asserting the return does not re-fulfil.

Redelivery test. Added testItFulfilsExactlyOnceAcrossRedeliveries, which enables webshop_auto_fulfill_enabled, fakes OrderCompleted and asserts a single dispatch across two deliveries.

Translation keys renamed to payment_processing / payment_processing_message in all 23 locales and both Vue consumers.

tests/Webshop passes (298 tests), as do phpstan and php-cs-fixer.

@ildyria

ildyria commented Sep 5, 2026

Copy link
Copy Markdown
Member

@payzum-hq it will take me a bit of time to review this PR, but it is on my radar.

@ildyria ildyria left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is in good shape, however there are parts which need refactoring otherwise the overall quality of Lychee will make it an unmaintainable dragon. And I would like to keep that one tamed.

private function handleAsyncPaymentReturn(Order $order, PayzumGateway $gateway, array $metadata): Order
{
if ($order->status !== PaymentStatusType::PROCESSING) {
// Already settled, e.g. the notification landed before the redirect.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you 100% it is already settled ?

In normal flow we just throw:
throw new LycheeLogicException('Order with invalid status.');

*
* @return Order The updated order
*/
public function handlePaymentNotification(Order $order): Order

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is adding to much code which is Payzum specific into the CheckoutService.
This approach is not clean and is more of a hack and will prevent Lychee from being maintainable in the future.

The flow of Mollie and Paypal are relatively similar.
Hower in this specific case this is WIDELY different due to the asynchronization.

I would recommend to put those functions in a different class and use injection for it so that the logic of Payzum is centralized.

);
}

if (!$success && $order->status === PaymentStatusType::PROCESSING) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to add a gate on the provider here.

Comment thread app/Http/Requests/Checkout/FinalizeRequest.php
*/
class NotifyRequest extends BaseApiRequest implements HasBasket
{
public const ORDER_ID_ATTRIBUTE = 'order_id';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is Request constant for this ?
If not, add it to the contracts instead of here.

*/
public function authorize(): bool
{
return $this->order?->provider === OmnipayProviderType::PAYZUM &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very much Payzum oriented, I would be more in favour of having the asynchronous flow available globally with a provider such as Payzum as one of the option.

That would allow more extensibility in the future.
Look at strategy design pattern: https://refactoring.guru/design-patterns/strategy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants