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
// notification before the buyer's browser returns; that return is
// still legitimate and must be able to show the completed order.
$is_valid_status = $this->order?->status === PaymentStatusType::PROCESSING ||
($this->order?->status === PaymentStatusType::COMPLETED && $this->provider_type === OmnipayProviderType::PAYZUM);

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Verify the identifier used by the Payzum browser return.
rg -n -C 8 \
  'shop\.checkout\.return|returnUrl|findByTransactionId|markAsPaid|transaction_id' \
  app routes tests

Repository: LycheeOrg/Lychee

Length of output: 50373


🤖 get_repo_knowledge executed:

get_repo_knowledge LycheeOrg/Lychee /tmp/coderabbit-repo-knowledge/lycheeorg-lychee-cc271fdf/learnings

Length of output: 9424


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- FinalizeRequest ---'
cat -n app/Http/Requests/Checkout/FinalizeRequest.php | sed -n '1,130p'

printf '%s\n' '--- NotifyRequest ---'
fd -i 'NotifyRequest.php' app | xargs -r -n1 sh -c 'cat -n "$0" | sed -n "1,180p"'

printf '%s\n' '--- Checkout controller and Payzum references ---'
rg -n -C 12 'function (finalize|notify)|shop\.checkout\.return|shop\.checkout\.notify|Payzum|payment_url|return_url|returnUrl|order_id' app routes tests --glob '*.php' | head -n 320

printf '%s\n' '--- Order lookup and payment mutation ---'
rg -n -C 10 'findByTransactionId|markAsPaid|transaction_id' app/Models app/Services app/Http --glob '*.php' | head -n 240

Repository: LycheeOrg/Lychee

Length of output: 49358


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- Checkout controller definitions ---'
fd -i 'CheckoutController.php' app | xargs -r -n1 sh -c 'rg -n -C 18 "function (createSession|process|finalize|notify)" "$0"'

printf '%s\n' '--- Checkout service payment URL and notification flow ---'
fd -i 'CheckoutService.php' app | xargs -r -n1 sh -c 'rg -n -C 16 "returnUrl|return_url|notify|notification|markAsPaid|transaction_id|shop.checkout" "$0"'

printf '%s\n' '--- Payzum provider implementation ---'
rg -l -i 'payzum' app config composer.json | sort | while read -r file; do
	printf '%s\n' "--- $file"
	rg -n -C 10 -i 'payzum|returnUrl|return_url|notify|transaction_id|order_id' "$file"
done

Repository: LycheeOrg/Lychee

Length of output: 45036


Preserve a stable lookup key for Payzum returns.

CheckoutController::process() puts the original Order::transaction_id in the Payzum return URL. handlePaymentNotification() later replaces it with the provider reference. FinalizeRequest::processValidatedValues() then calls Order::findByTransactionId() before the COMPLETED Payzum branch, so the browser return can fail with “Order not found.”

Use the order ID in the return route or preserve the original transaction ID. Add a regression test for notification completion before the browser return.

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.

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.

1 participant