hardcastle / ledger-direct-sw6
LedgerDirect - Shopware6 Ledger & Blockchain Connector
Package info
github.com/ledger-direct/ledger-direct-shopware6
Type:shopware-platform-plugin
pkg:composer/hardcastle/ledger-direct-sw6
Requires
- php: ^8.2
- hardcastle/ledger-direct-core: ^0.10
- shopware/core: ~6.6.5 || ~6.7.0
- shopware/storefront: ~6.6.5 || ~6.7.0
Requires (Dev)
- mockery/mockery: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 1.9.0
- 1.8.0
- 1.7.0
- 1.6.0
- 1.5.2
- 1.5.1
- 1.5.0
- 1.4.4
- 1.4.3
- 1.4.2
- 1.4.1
- 1.4.0
- dev-feat/headless-complete
- dev-chore/1.8.0-release-prep
- dev-feat/headless-redirect
- dev-feat/headless-quote
- dev-chore/packagist-ready
- dev-feat/tx-ctid-column
- dev-docs/payment-page-screenshot
- dev-chore/config-yaml
- dev-feat/admin-payment-card
- dev-fix/job-syncs-configured-account
- dev-chore/core-0.8.1
- dev-ci/docker-pulls
- dev-docs/readme
- dev-ci/e2e-nightly
- dev-feat/payment-ui-package
- dev-feat/payment-status
- dev-feature/add-xrpl-wallets
- dev-test/check-nobuild-checkout
This package is auto-updated.
Last update: 2026-10-29 15:25:24 UTC
README
Accept XRP, RLUSD and USDC directly on the XRP Ledger — no payment processor, no custody, funds land in the merchant's own wallet.
The plugin is the Shopware 6 adapter over
hardcastle/ledger-direct-core, the
shared package that holds the XRPL and pricing logic. Everything platform-specific — the payment
handlers, order transaction states, the payment page, the settings cards, the scheduled task — lives
here; price conversion, the oracle set, the stablecoin registry and ledger sync live in the core and
are not reimplemented.
Project website: https://www.ledger-direct.com
How it works
A customer picks XRP, RLUSD or USDC at checkout; each is a payment method of its own. The order is placed straight away with its transaction Open and the customer gets a payment page: the exact amount, the shop's receiving address, and a destination tag unique to that order. The tag is what ties an incoming ledger transaction back to the order, so one wallet address serves every customer.
The quote is fixed for a configurable window (five minutes by default). Reloading the page never changes the amount; once the quote lapses the customer can ask for an updated one, and the destination tag stays the same so a payment already in flight is not orphaned.
Payments are confirmed three ways, which is deliberate redundancy:
- the payment page polls while the customer is watching,
- a check now button for anyone who would rather not wait (and for browsers without JavaScript),
- a scheduled task that settles orders for customers who closed the page.
An order is credited only from the ledger's delivered_amount, only when what arrived covers the
requested amount, and — for stablecoins — only when the currency and issuer match. Several payments
in the quoted asset add up, so a customer who sent too little can send the rest. Anything else stays
open, and the payment page says why: it shows one of five states — waiting, expired, partial payment
(with the outstanding amount), wrong token (with the full amount still due), or paid — and updates
in place while the customer watches, without reloading.
The transaction state in the administration follows the ledger. Paid once the order is settled, whether or not the customer is still looking at the page. Paid partially as soon as something arrives that does not settle the order — also for a payment in another token or from another issuer, where nothing is credited but money is there and Shopware has no closer state. The numbers behind that state are on the LedgerDirect card of the order detail (tab Details, under the transaction card): the payment's state in the core's five words, what was quoted (asset, amount, rate, receiving account, destination tag, issuer for a token, quote validity), what arrived — naming the other token and its issuer when it was the wrong one —, what is still due, and the transaction as a link to the XRPL explorer. The card is read-only and computed on the server; it is the same payload the payment page polls. A transaction the merchant closed by hand (cancelled, failed, refunded) is never reopened by a late payment.
The page asks the server every 8 seconds. That endpoint syncs with the XRPL node at most once every 5 seconds per receiving account, whatever the number of customers waiting; in between it answers from what is already stored. A guest order can poll too — the link carries the order's deep link code, the same one Shopware uses for its guest order links, and no login is required.
Requirements
- Shopware 6.6.5 or newer, or 6.7
- PHP 8.2 or newer
- An XRP Ledger account. Accepting RLUSD or USDC additionally requires a trustline to the respective issuer on that account, or payments will fail on the ledger.
- For the Stellar preview (see Configuration): a Stellar account, with a trust line to Circle's issuer for USDC or EURC.
Installation
Three ways in; the result is the same plugin.
With Composer, from Packagist — the
way for a shop whose plugins are managed in composer.json, and the way Shopware recommends for
production. The core comes along as a dependency; the git tags are the versions:
composer require hardcastle/ledger-direct-sw6
bin/console plugin:refresh
bin/console plugin:install LedgerDirect --activate
bin/console assets:install
bin/console cache:clear
Updates are composer update hardcastle/ledger-direct-sw6 followed by bin/console plugin:update LedgerDirect.
As a zip in the administration (Extensions → My extensions → Upload extension): the release zip
from the GitHub releases. Installing
then installs the core from Packagist into the shop — the plugin asks Shopware to run Composer for it
(executeComposerCommands()), because Shopware never loads a vendor directory shipped inside a
plugin — so the shop needs to reach the network during that step. In a cluster setup
(shopware.deployment.cluster_setup: true) Shopware skips that Composer step for every plugin;
there, install through Composer as above, or require hardcastle/ledger-direct-core in the shop's
root composer.json before uploading the zip.
From a checkout, for development: clone this repository into custom/plugins/LedgerDirect, then
bin/console plugin:refresh
bin/console plugin:install LedgerDirect --activate
bin/console cache:clear
The core is installed into the shop as for the zip. The storefront and administration bundles are committed, no build is needed.
Installing creates the three payment methods and two tables for ledger data (synced transactions and the destination-tag counter); activating the plugin switches the payment methods on, assigning them to sales channels is still yours to do (see Configuration). Uninstalling deactivates the payment methods and deliberately keeps the tables: the destination-tag counter cannot be reconstructed, and reusing tags would match new orders against old payments.
Updates run the plugin's migrations through Shopware's usual plugin:update.
Configuration
Settings → Extensions → My extensions → LedgerDirect → Configure. One card per chain, plus the payment page.
LedgerDirect – XRP Ledger
| Setting | |
|---|---|
| Accept payments on the XRP Ledger | The chain as a whole. Off, none of its payment methods is offered at checkout. On for every installation that has a receiving address configured. |
| Use testnet | On by default. The receiving address must belong to the selected network. |
| Merchant wallet address, mainnet and testnet | The shop's receiving account on each network; the one for the selected network is used. Checked when saved: an address that is not an XRPL account is refused. |
| Enable RLUSD payments, Enable USDC payments | Whether the stablecoin payment methods are offered; the receiving account needs the trustline first. XRP needs no switch. |
| Quote validity in seconds | How long a quoted amount stays fixed, on every chain. Default 300. |
LedgerDirect – Stellar (Preview)
Stellar support is complete and tested on the testnet, but shipped as a preview: not for production use yet. Until the card's switch is on, nothing of it shows anywhere — no payment methods under Settings → Payment methods, nothing at checkout. Switching it on creates the XLM, USDC and EURC payment methods; switching it off hides them at checkout again. The card can be removed from the preview without a change for anyone who never switched it on.
| Setting | |
|---|---|
| Accept payments on Stellar (preview) | Off by default. On, with a receiving account, XLM is offered; USDC and EURC are offered as well once their switch below is on and the receiving account has a trust line to the issuer. |
| Use testnet | On by default. |
| Receiving account, mainnet and testnet | The public key of your Stellar account (G…). Checked when saved. |
| Enable USDC payments, Enable EURC payments | Circle's USDC and EURC. Offered at checkout only while the receiving account has a trust line to the issuer; the plugin checks that through Horizon, cached. |
A payment method is offered at checkout only while its chain is on, has a receiving address and the
asset is enabled — and, for a Stellar issued asset, while the receiving account has its trust line.
The assets, the payment methods and the address format come from the core's ChainCatalog. On
Stellar the customer puts the order's memo id (memo type ID) on the payment where the XRPL uses a
destination tag; the QR code carries a SEP-7 payment request.
LedgerDirect – Payment page
| Setting | |
|---|---|
| Logo | What the payment page shows in its header: the theme's shop logo, a picture from the media library, or the first letter of the shop name. |
| Accent colour | The one colour of the payment page — buttons, countdown bar, destination tag — with white text on it, so it must be dark enough (contrast 4.5:1). Default #1f5eff; a lighter colour falls back to the default. |
| Xaman API key, WalletConnect project id | Public identifiers, optional. With one of them, customers on a phone get an "Open in wallet app" button. Restrict them to your shop domain in the respective dashboard. |
Issuer addresses are not configurable. They are fixed in the core: a wrong issuer would send customer funds to a dead trustline.
Then assign the payment methods to the sales channels that should offer them, under Settings →
Shop → Payment methods (and switch them on there if the plugin was installed without --activate).
The scheduled task ledger_direct.settle_open_transactions runs every five minutes on Shopware's
message queue, so a worker (messenger:consume or the admin worker) has to be running. Without it,
a customer who closes the payment page before their transaction confirms is only settled the next
time someone visits their payment page.
Test payments
Leave Use testnet on and enter a testnet account as the testnet wallet address. Test accounts come from the XRP Testnet faucet for XRP, the RLUSD faucet for RLUSD and the Circle faucet for USDC; the receiving account needs the trustlines, the paying wallet needs the tokens.
For the Stellar preview, a testnet account comes from Friendbot (10 000 XLM); Circle's faucet above also serves USDC and EURC on the Stellar testnet. The customer puts the order's memo id on the payment where the XRPL uses a destination tag; the QR code is a SEP-7 payment request that a Stellar wallet reads with address, memo and amount.
The payment page
After checkout the customer lands on /ledger-direct/payment/{orderId}?deepLinkCode=…, a storefront
page that keeps the theme's <head> and stylesheets but replaces the body: no header, navigation or
footer. The same controller answers the "check now"
button and the quote refresh; the page polls
/store-api/ledger-direct/payment/check/{orderId}?deepLinkCode=…, which returns the same payload as
every other LedgerDirect plugin (INVARIANTS.md in the core, "Payment status"), plus a redirect
URL once the order no longer waits for payment.
Behaviour and design come from @ledger-direct/payment-ui,
the package every LedgerDirect plugin shares: the five payment states, the countdown, polling,
copy buttons, the QR code with address, destination tag and amount, and browser wallets over
XRPL Connect (Crossmark, GemWallet, MetaMask Snap, Ledger, Otsu, Xyra; Xaman and WalletConnect
with the identifiers above). src/Resources/app/storefront/src/package.json pins the package's
tag; main.js is the Shopware hull that registers the page as a storefront plugin, and the
template renders the package's markup contract (src/README.md there). Every sentence a customer
reads is in the template and the snippet files; nothing is rounded or reformatted in the browser.
The package's stylesheet is committed as scss/payment-page.css next to the plugin's SCSS: Shopware
compiles a plugin's SCSS on the server, in the merchant's shop, where node_modules does not exist.
To move to a new package version: change the tag in package.json, run npm install and
npm run sync in that directory, rebuild the storefront with Shopware's bin/build-storefront.sh
(after bundle:dump, or the plugin bundle is silently skipped) and commit
src/Resources/app/storefront/dist/ together with the stylesheet copy. A unit test compares the
copy with the installed package.
Without JavaScript the page still works: the amount, address and tag are server-rendered, the QR code is drawn on the server from the same payment request, and the "Check payment now" button is a plain form.
Headless / external front end
A shop whose front end is not Shopware's storefront — Shopware Frontends, a React or Nuxt app, an app —
places the order through the Store API and renders its own payment page. The plugin's Store API routes
answer it, under the sales channel's sw-access-key like every Store API call:
| Route | Answers |
|---|---|
GET /store-api/ledger-direct/payment/quote/{orderId}?deepLinkCode=… |
the values the payment page is built from: the flat fields from the first release and, under page (schema_version 1), the complete set — state, seconds left, the amounts as the page shows them, account, destination tag or memo id, issuer and currency code for a token, the payment request for the QR code, explorer base, accent colour, wallet-app identifiers. The same set the storefront's own page renders from |
POST /store-api/ledger-direct/payment/refresh/{orderId}?deepLinkCode=… |
a current quote, re-quoting an order whose quote has expired — the "quote expired" button; an order with a payment on record gets its current quote back unchanged; account and tag stay as they were unless the merchant changed the receiving account in between |
GET /store-api/ledger-direct/payment/check/{orderId}?deepLinkCode=… |
the payment status (INVARIANTS.md in the core, "Payment status") plus hash once a payment is on record and redirect once the order no longer waits; polls the ledger, throttled |
Access is the order's deepLinkCode — the code behind the guest order link in the confirmation mail —
or the customer of the session, no login. A wrong code and another customer's order are answered
alike with 403; an order never quoted with LedgerDirect gets 404; a quote or refresh for an order
that no longer waits for a payment 410 (check carries the redirect for it). An expired quote is
handed out as the page shows it, page.state expired, and refresh makes a current one. The three
routes are described in the Store API's OpenAPI document, /store-api/_info/openapi3.json, under the
tag LedgerDirect. The Store API answers cross-origin requests with
Shopware's CORS headers, on these routes as on the core's.
The payment page package renders from page and polls check; pass the access key with
startPaymentPage(root, { headers: { 'sw-access-key': … } }) (package 0.3.0), because the Store API
needs it where the storefront route did not.
Where the customer is sent. By default handle-payment answers the storefront's payment page
(/ledger-direct/payment/{orderId}?returnUrl=…&deepLinkCode=…, a relative path) and a payment arriving
after Shopware's payment token has expired lands on the storefront's order page. The card
"LedgerDirect - External front end" in the configuration moves both to your front end, per sales
channel: two URL templates, filled in URL-encoded, empty by default.
| Setting | Placeholders | Used |
|---|---|---|
| Payment page of the external front end | {orderId}, {deepLinkCode} required, {returnUrl} optional |
the redirect handle-payment answers after the order |
| Order page of the external front end | {deepLinkCode} required, {orderId} optional |
the redirect of check once the order no longer waits and the payment token has expired; while the token lives, redirect is Shopware's finalize route, which lands on the finishUrl you gave handle-payment, whatever its origin |
A template that is not an absolute https:// URL or lacks a required placeholder is refused when saved:
without the code a guest could never open the page, and over plain http:// the code and the payment
token in {returnUrl} would cross the wire in the clear (http:// is accepted for localhost, 127.0.0.1
and .localhost/.test names, a front end on the developer's own machine). Both targets come from the configuration alone, never from
the request or the payment token, so the payment page cannot become an open redirect. Set the templates
on the headless sales channel only; the storefront's channels keep their own pages.
External services
Exchange rates come from the public APIs of Coingecko, Binance and Kraken, through the core. No personal or payment data is sent to them; only the current rate is requested when a payment is quoted or displayed, and rates are cached in the shop's object cache so a short outage of one source does not interrupt checkout.
- Coingecko API: Terms of Service, Privacy Policy
- Binance API: Terms of Use, Privacy Policy
- Kraken API: Terms of Service, Privacy Policy
Development
How the whole of LedgerDirect is tested across the core, the shared page package and the four
plugins — the layers, what each catches, the nightly end-to-end runs and the manual cases — is in
docs/testing.md of the core.
The manual cases for this plugin, by the core's case IDs, are in tests/Manual/payment-status.md.
The plugin is developed inside a Shopware installation (a dockware container in CI and locally); the tests run with the shop's PHPUnit, from the plugin directory:
/var/www/html/vendor/bin/phpunit --testsuite Unit
/var/www/html/vendor/bin/phpunit --testsuite Integration
make phpstan
The unit suite builds the real core services and stubs only the edges the platform provides (a
PSR-18 client, the port implementations); the integration suite needs the booted shop and its
database and is offline by design. After a version bump in composer.json, run
APP_ENV=test bin/console plugin:refresh as well: the test environment has its own plugin registry.
To work against a local core checkout instead of the released version, add a path repository to the
shop's composer.json and require the branch; keep the plugin's own constraint on the released
version:
composer config repositories.ledger-direct-core path ../LedgerDirectCorePHP/ledger-direct-core-php
composer require hardcastle/ledger-direct-core:dev-master
A cache that survives the request
The payment-status endpoint throttles ledger syncs per receiving account through cache.object, and
the core caches exchange rates in the same pool. Shopware's own dev configuration backs that pool
with the in-memory array adapter, so in a dev shop both are silently void: every poll is a node
request. Set framework.cache.app to cache.adapter.filesystem (or Redis) in
config/packages/dev/ when you test the payment page in APP_ENV=dev. Production configurations
are unaffected. Each actual sync is logged at debug level as LedgerDirect: ledger synced, which
is how you see whether the throttle holds.
Continuous integration and release
Every push and pull request runs .github/workflows/ci.yml: shopware-cli extension validate
(basic and store-compliance checks as a gate, the full validation non-blocking) and both PHPUnit
suites in a dockware container with Shopware 6.7. e2e.yml runs the end-to-end harness against a
fresh dockware shop and the XRPL testnet every night; it is not a merge gate, it detects drift.
A release is a git tag that matches the version in composer.json (Shopware reads that field
for a plugin under custom/plugins; Packagist reads the tag). Pushing the tag runs release.yml:
it refuses a tag that does not match composer.json, builds the store zip from that commit with
shopware-cli extension zip, validates it, and publishes a GitHub release with the zip as its asset
and the tag's section of CHANGELOG_en-GB.md as its notes. Packagist picks the tag up on its own.
.shopware-extension.yml tells shopware-cli to ship the committed bundles as they are rather than
building the assets again, and leaves tests and tooling out; .gitattributes does the same for the
Composer dist archive. CHANGELOG_en-GB.md and CHANGELOG_de-DE.md are the changelogs the store
shows, so the English one needs its # <version> section before the tag.
Translations
English and German, as snippet files under src/Resources/snippet/.
License
MIT — see LICENSE.md.
