Repository navigation
Releases
0.11.0
Xahau is the third chain. It speaks the XRP Ledger's protocol on its own network, so it gets no sibling stack like Stellar: the XRPL classes take the chain as a parameter, every new parameter defaults to XRPL, and the XRPL tests are unchanged. An order can be quoted in XAH, paid by destination tag, synced from Xahau's nodes and settled under the same rule as on the XRPL.
Added
Chain XAHAU. ChainCatalog lists it: label "Xahau", XAH native with 5 places, type xahau-xah-payment, the XRPL account format and checksum, "Destination tag", no issued assets. PaymentIntent knows XAH as its native asset.
Endpoints. Xrpl\XrplClient takes the chain; Xahau's nodes are https://xahau.network/ and https://xahau-test.net/, fixed like the XRP Ledger's. A refused call (any non-2xx, so 429 and 503 too) carries the node's Retry-After in its message; nothing sleeps inside a shop request. Messages name the chain.
Price. Price\XahPriceProvider reads Coingecko (xahau) and the new Price\Oracle\BitrueOracle (XAHUSDT, no key), averaged through the divergence filter like every oracle set. XAH trades on Bitrue alone, so the two feeds report one market: they catch a stale or broken aggregator, not a manipulated market. Bitrue quotes USDT only; an EUR quote rests on Coingecko.
Quote, tables, root. Payment\PaymentIntentService takes the chain and reads the configuration under it; PriceService::getCryptoPriceForOrder() prices XAH like XRP. Xrpl\Schema::tables('XAHAU') and mysql(…, chain: 'XAHAU') describe ledger_direct_xahau_tx and ledger_direct_xahau_destination_tag, the XRPL columns under names of their own, index names carrying xahau. LedgerDirect::create(…, chain: 'XAHAU') passes the chain on; a shop that takes both chains holds two roots with two repositories.
Payment request. On Xahau Payment\PaymentUri adds network=XAHAU or network=XAHAUTESTNET, so Xaman offers the switch when it is set to another network (verified on the Xahau testnet, 2026-11-06). XRPL requests are unchanged.
Contract. INVARIANTS.md has a section "Xahau": names in the record, tables (nothing shared with the XRP Ledger, not even for an account that exists on both), endpoints, oracles, payment request, and the known gaps Remit, Hooks and AccountDelete. docs/testing.md has the Xahau rows; the fixtures under tests/Xahau/fixtures/ are account_tx answers recorded on the Xahau testnet.
Changed
Xrpl\SyncThrottle keys. The chain is part of the key (the same r… address can receive on the XRP Ledger and on Xahau), and the account is in it as a digest: PSR-16 guarantees 64 characters, and a Stellar key spelled out never fit. Prefix ledger-direct.sync.v2; a mark lives five seconds, so nothing carries over. The Stellar root passes STELLAR.
PaymentIntentService refuses an asset the chain does not have with "Unsupported base_asset"; on XRPL the message used to point to quoteAmount().
Fixed
XrplClient::tx() by CTID. A CTID was sent as transaction, which rippled and Xahau read as a malformed hash and refuse with notImpl; it goes as ctid now. The plugins' ledger-direct:xrpl-transaction:lookup --ctid never worked before this. The sync does not use this path.
PriceService rate cache. A cached rate that is not a positive, finite number reads as a miss instead of failing the quote with a division by zero.
Tests
338, 24 of them for Xahau. The opt-in integration suite is unchanged.
0.10.0
Stellar is the second chain. An order can be quoted in XLM, USDC or EURC, paid by memo id, text memo or muxed destination, synced from Horizon and settled under the same rule as on the XRPL — the payment record, the settlement policy and the five payment states are unchanged and shared.
Added
Core\Stellar\… and Port\StellarPaymentRepositoryInterface. A sibling of the XRPL stack, not a generalized multi-chain port: HorizonClient behind the internal PaymentSourceInterface (fixed SDF endpoints, 429 with Retry-After surfaced as HorizonException), SyncService (cursor per account and network, dedup by (hash, op_index), testnet-reset detection from the cursor's ledger bits with RESET_LEDGER_MARGIN, findFulfillmentFor() under the sum rule), StellarPayment, StellarAmount, Fulfillment, MemoIdService on the shared Identity\SequencePermutation, PaymentIntentService (stellar-xlm-payment, stellar-usdc-payment, stellar-eurc-payment), Schema (ledger_direct_stellar_payment, ledger_direct_stellar_memo) and the composition root LedgerDirectStellar::create(). PaymentIntent::NATIVE_ASSET_BY_CHAIN knows STELLAR => XLM.
Identifiers as decimal strings. The memo id, a muxed destination's id and a canonical MEMO_TEXT all name the same order; a foreign identifier may use the full uint64, so the repository port and the stored column carry strings and BIGINT UNSIGNED. INVARIANTS.md, "Stellar", has the precedence and the canonical-form rule.
Stellar\StablecoinRegistry with Circle's USDC and EURC issuers for mainnet and testnet, copied from Circle's documentation and confirmed against Horizon; the excerpt and Horizon's answers are checked in under tests/Stellar/fixtures/registry/ and the test compares the constants with both. Stellar\AccountInspector with Trustline and IssuerFlags answers the preconditions an adapter checks before quoting an issued asset (account exists, trust line with headroom, issuer flags), cached over PSR-16. Stellar\PaymentUri builds the SEP-0007 request the QR code encodes, memo always included — not yet verified by a wallet scan; the first adapter does that.
Prices. Price\XlmPriceProvider (Binance, Coingecko, Kraken, divergence filter), Price\EurcPriceProvider (Coingecko, EUR peg — the first non-USD peg), Price\AmountQuote and PriceService::quoteAmount() as the chain-neutral half of the quote; getCryptoPriceForOrder() delegates and keeps refusing non-XRPL assets.
Core\ChainCatalog, ChainDescriptor, AssetDescriptor. The chains and their assets as data for the adapters' configuration screens (label, native asset, networks, identifier label, account-format check; per asset the intent type, decimals, peg, issuer per network, trust-line requirement), derived from the intent services and registries and pinned by tests. forType() finds chain and asset from an intent's type. Additively extensible; the NFT strand will add capabilities here.
Sync\LedgerSyncInterface. Both syncs implement it; SyncThrottle::syncIfDue() accepts it (parameter type widened, nothing else changed).
Testing kit. Testing\InMemoryStellarPaymentRepository and StellarPaymentRepositoryContractTestCase for adapters, recorded Horizon answers from the testnet under tests/Stellar/fixtures/ (public data only, and only what a test reads), and the opt-in tests/Integration/Stellar/HorizonIntegrationTest.
Changed
INVARIANTS.mdgains the "Stellar" section (names in the record, identifiers, payment source, tables, testnet reset, oracles and pegs, preconditions, registry, known gaps) anddocs/testing.mdthe Stellar rows.- The 0.8.1 rule holds on Stellar as it does on the XRPL: a payment in the other asset class — XLM on a USDC order, an issued asset on an XLM order — is a foreign candidate,
wrong_assetwith the delivered amount when nothing in the quoted asset has arrived, put aside and logged once something has. An earlier state of the branch skipped it. - The schema test for the Stellar tables holds the 0.9.0 rule that no column may take a PostgreSQL system column name.
Fixed
- A payment from another network never counts, on either chain. The same key pair is an account on testnet and mainnet, the tag and memo counters are per account, and a refreshed quote keeps account and identifier. A merchant who switched the network could see a stored testnet payment settle a mainnet order, or show as
wrong_asset. Both syncs now skip a candidate whosenetworkdiffers from the intent's, with a warning, before any other check;INVARIANTS.md, "Settlement", states the rule. The XRPL sync had this gap since 0.1. Found by CodeRabbit. Stellar\PaymentUrinames the testnet passphrase (network_passphrase) on testnet requests; SEP-0007 assumes the public network without it.StellarPayment::isCanonicalIdentifier()and the account patterns inChainCatalogreject a trailing newline.PriceService::getCryptoPriceForOrder()refuses a non-XRPL asset before any oracle work.
Not in this release
RPC as a second payment source (CAP-67 transfer and mint events, to_muxed_id as a string for text memos) and Soroban/SAC transfers arriving as invoke_host_function. Both documented under "Stellar", Known gaps.
Upgrading
Nothing changes for XRPL code; no existing public symbol changed. An adapter that adds Stellar creates the two tables from Stellar\Schema::tables() or Stellar\Schema::mysql(), implements StellarPaymentRepositoryInterface (and runs the contract test case against it), configures the receiving account per chain through ConfigProviderInterface and composes with LedgerDirectStellar::create(). The configuration screens come from ChainCatalog; the cross-adapter handover "Chain-Auswahl in der Konfiguration" describes the layout.
Full Changelog: 0.9.0...0.10.0
0.9.0
The schema works on PostgreSQL: the CTID moves to a column PostgreSQL does not reserve, and a malformed CTID is refused before any database can truncate it.
Changed
Xrpl\Schema stores the CTID in tx_ctid, not ctid. PostgreSQL gives every table the system columns tableoid, xmin, cmin, xmax, cmax and ctid, and no user column may take one of those names; quoting does not help. ledger_direct_xrpl_tx could therefore not be created on PostgreSQL (SQLSTATE[42701]: column name "ctid" conflicts with a system column name), found through the Laravel bridge. The column is tx_ctid now, VARCHAR(16) NOT NULL as before. The PaymentIntent field ctid, XrplTransaction::$ctid and every port and service signature are unchanged; no core code reads physical column names, so column and record field are named differently on purpose. INVARIANTS.md documents the name and the rule.
A malformed CTID is refused on hydration. SyncService::hydrate() skips, and logs, a ledger entry whose ctid is not C followed by fifteen upper-case hex digits (XrplTransaction::CTID_PATTERN, isValidCtid()), the way it already skips one without a hash. The column holds sixteen characters and not every database refuses a longer value: MySQL truncates silently under INSERT IGNORE, WordPress switches the strict modes off, SQLite checks no length at all. The check sits before storage so that no adapter needs its own; it is not in the XrplTransaction constructor, so rows already stored with a truncated CTID still hydrate.
Added
Schema::POSTGRES_SYSTEM_COLUMNS, and a schema test that no column of any table the core describes may use one of those names. Xahau inherits it throughXrpl\Schema; the Stellar schema follows when it is rebased.docs/testing.mdon the versions of 2026-10-10 (Shopware 1.4.4, PrestaShop 0.5.1, Magento 1.1.1, WooCommerce 1.4.0, harness 0.1.0), with two open points: PS-04 is proven across asset classes on all four platforms, and the WooCommerce PS-11 gap, a payment on a cancelled order staying invisible until the receiving account syncs again, with the follow-up the plugins get next.
Fixed
XrplTransactionRepositoryContractTestCase::transaction()used a 23-character CTID for a 16-character column. It only passed where storage truncated silently or checked no length; PostgreSQL rejected it. The fixture is a real CTID now (C000006400000001), likewise inSyncServiceTestandXrplTransactionTest.
Upgrading
Nothing changes for code that goes through the ports and services; the payment record keeps its ctid field.
What changes is the table. An adapter that creates its tables from Schema::tables() or Schema::mysql() gets tx_ctid on new installations; today that is only the unreleased Laravel bridge. The four shop plugins carry their own DDL and are unaffected by this release on its own: each follows with a rename migration in its next release, idempotent, and raises its constraint to ^0.9 in the same change, so that no installation runs a core that expects tx_ctid against a table that still has ctid:
ALTER TABLE ledger_direct_xrpl_tx CHANGE COLUMN ctid tx_ctid VARCHAR(16) NOT NULL;Until those plugin releases are out, keep the plugins on ^0.8.1.
Full Changelog: 0.8.1...0.9.0
0.8.0
0.8.0
Core\Payment\PaymentUri: the payment request behind the QR code and the wallet-app buttons, one specification for every platform (https://xrplf.org//send?to=…&dt=…&amount=…[¤cy=…&issuer=…], amount as displayed, verified with Xaman on the testnet).Core\Presentation\AccentColor: a merchant's accent colour is used only if it carries white text at WCAG 4.5:1.INVARIANTS.md: new section "Payment request".
No breaking changes.
0.7.0
What a Laravel service provider or a Symfony bundle needs from this library: one entry point, and four things every adapter had been writing for itself. No framework code enters the core — the bridges (ledger-direct-laravel, ledger-direct-symfony) come as separate packages on top of this. Purely additive; the only new dependency is psr/clock ^1.0.
New: LedgerDirect, the composition root (#7)
Four adapters wire five services by hand. A fifth would have done it again, and every constructor change lands once per adapter. LedgerDirect::create() takes every port once — PSR-18/17 client and factories, PSR-3 logger, the two repository and config ports, an optional PSR-16 cache, an optional PSR-20 clock — and hands out every service, built lazily and memoised: priceService(), xrplClient(), syncService(), destinationTagService(), paymentIntentService(), settlementPolicy(), syncThrottle() (null without a cache) and paymentStatus($intent).
The services stay publicly constructible. The root is the only wiring the core promises to keep stable across releases. There is no static state and no facade — a facade belongs to the platform bridge that has a container to resolve it from.
New: PSR-20 clock
PaymentIntentService and PriceService take an optional ClockInterface, defaulting to Clock\SystemClock. PaymentStatus::fromIntent(?int $now) is unchanged. Symfony's MockClock and Laravel's frozen time now reach quote expiry and rate-cache freshness; the shipped FrozenClock does the same in plain PHPUnit, and InMemoryCache shares the clock of the service under test so advancing it ages both.
New: LedgerDirectException
One marker on the four runtime failures — PriceUnavailableException, XrplRpcException, AssetNotAcceptedException, DestinationTagsExhaustedException — so one catch in a platform's exception handler covers them. Deliberately not on the InvalidArgumentExceptions the value objects throw: those are programming errors, not conditions a running shop recovers from.
New: Xrpl\Schema
The DDL template INVARIANTS.md ("Tables") promised and never had. Schema::tables() describes both tables as data — columns, types, indexes — for a Laravel migration or a Doctrine table; Schema::mysql($prefix) is ready DDL for the MySQL platforms and reproduces the PrestaShop schema apart from prefix, engine and the primary key's name (id). The network column, the unique hash, the cursor index on (destination, network, ledger_index) and the unsigned destination_tag are all pinned by test.
New: Xrpl\SyncThrottle
The status-endpoint throttle, in the core instead of once per adapter: shouldSync() / markSynced() / syncIfDue(), over the same PSR-16 cache as the rate cache, keyed by network and account — the sync is per account, so throttling per order would still let every waiting customer trigger a node request. A broken cache degrades to "always sync", never to "never sync". INVARIANTS.md "Payment status" now says an adapter uses it or an equivalent.
New: src/Testing/ ships with the package
The fixtures that lived under tests/ — InMemoryXrplTransactionRepository, FakeHttpClient, FakeConfigProvider, InMemoryCache, RecordingLogger — plus FrozenClock and XrplTransactionRepositoryContractTestCase: the repository contract as a suite an adapter extends. Newest-first ordering with a total tie-break, the cursor scoped by account and network, the random counter start that is never 0 — each once cost an adapter a mis-settled or stranded order. PHPUnit stays a dev dependency plus a suggest; the in-memory repository passing the suite is what keeps the suite honest.
What adapters do with this
Nothing is required. Optionally: replace the hand-written service wiring with LedgerDirect::create(), use syncThrottle()?->syncIfDue() in the status endpoint, generate migrations from Schema, and run the repository implementation through the contract test. PrestaShop's ServiceFactory reduces to one call; its handover's throttle step now points at the core class.
Notes
179 tests, green on PHP 8.2 / 8.3 / 8.4 plus the lowest-dependency job. Contract details in INVARIANTS.md (introduction, "Tables", "Payment status"); wiring and testing kit in README.md.
0.6.0
Partial payments add up. The payment-status contract from 0.5.0 tells the customer "X received, Y still outstanding" — this release makes sending Y actually settle the order. Purely additive: one new class, one new method, one deprecation, no port, signature or schema change. Adapters move with a one-line method swap.
The loop this closes (#6)
Order over 1.0 XRP, customer sends 0.3. The page says "0.3 received, send 0.7". They send 0.7. The core judged only the newest transaction on the tag, so the page then said "0.7 received, send 0.3" — and so on, with 1.3 XRP on the ledger and the order never paid. The only way to settle was a single transaction over the full amount, which on top of a partial payment is a double payment.
Until 0.5.0 this was a logged edge case. With the status contract it became an instruction to the customer, identical in four plugins, that could not be followed. Which transactions fulfill an intent is a core decision per INVARIANTS.md, so the fix is here, not in the adapters.
New: Fulfillment and SyncService::findFulfillmentFor()
findFulfillmentFor(PaymentIntent): ?Fulfillment returns every transaction on the tag in the quoted asset, newest first, together with their summed delivered amount:
- "Quoted asset" is the rule
SettlementPolicy::isWrongAsset()already states, used as a filter. For a native asset every native amount on the tag counts; for an issued currency only those with the quotedcurrencyandissuer. - A foreign token becomes the fulfillment on its own only when nothing in the quoted asset has arrived — so
wrong_assetstays reachable, with the delivered amount visible. As soon as one right payment exists, only those count and the foreign ones are logged. - The sum is exact via
brick/mathand comes back in the record shape: a float for native, a plain decimal string in the quoted envelope for issued.0.1 + 0.2reads"0.3", not0.30000000000000004. - Class mismatch, non-payment and the ledger's
"unavailable"are skipped and logged exactly as before.
Fulfillment carries transactions, amountPaid, hash() and ctid() of the newest contributing transaction, count(), and applyTo(PaymentIntent) — the one place an adapter gets all three values onto the intent. Schema v1 is unchanged: the intent records the newest contributing hash; the full list is on the Fulfillment and always recoverable via findTransactions().
Deprecated: findTransactionFor()
Behaves exactly as in 0.3–0.5 (its seven tests pass untouched) and is marked @deprecated since 0.6.0. An adapter that has not moved yet is not surprised — it just never adds partial payments up. Removed in 1.0.
What adapters do with this
Replace the three steps findTransactionFor() → getDeliveredAmount() → withFulfillment() with one line:
$fulfilled = $sync->findFulfillmentFor($intent)?->applyTo($intent);Raise the constraint straight to ^0.6. What a platform does with several contributing hashes — one payment record with the leading hash, or one per transaction — is its own business, like everything after "paid". Order per the handover: PrestaShop first, then Shopware, WooCommerce, Magento.
Notes
INVARIANTS.md, "Settlement", now states the sum rule, the foreign-token rule and the deprecation; "Payment status" notes that amount_paid in partial is the sum. No time filter on the summed transactions: the intent has no creation timestamp, and the tag's uniqueness (random counter start since 0.4) is the guarantee. 154 tests, green on PHP 8.2 / 8.3 / 8.4 plus the lowest-dependency job.
0.5.0
The core half of the payment-status contract (LedgerDirect-Zahlungsstatus-Vertrag.md): one answer shape for "is this order paid?" that every adapter serves, plus the wrong-asset rule it derives from. Purely additive — no port, signature or schema change, nothing an adapter has to do before raising its constraint to ^0.5.
New: PaymentStatus (#5)
Three plugins answered the same question in three formats — PrestaShop {status, redirect, seconds_left}, Shopware and Magento {success: bool}, WooCommerce no route at all — and none of them could report a partial payment or a wrong token, even though the freshly rendered payment page already shows both. A page after a poll knew less than the same page after a reload.
Core\Payment\PaymentStatus is derived, never stored: a pure function of PaymentIntent, SettlementPolicy and the clock. Five states, each of which a payment page has to be able to show:
| State | Meaning | Extra fields |
|---|---|---|
waiting |
Nothing arrived, quote still valid | seconds_left |
partial |
Arrived in the quoted asset, does not settle | amount_paid, shortfall |
wrong_asset |
Arrived, but another currency or another issuer | amount_paid, shortfall |
settled |
Paid — the only terminal state | amount_paid |
expired |
Nothing arrived, quote expired | — |
The derivation order is part of the contract: settled → expired → waiting → wrong_asset → partial. A partial payment on an expired quote is therefore partial, not expired — the customer sent real money, and the page has to say so before it talks about validity periods.
toArray() carries exactly schema_version, state, base_asset, amount_requested, amount_paid, shortfall, seconds_left, always all seven. shortfall takes the shape of amount_requested, with the quoted currency and issuer, so an adapter formats it through the same code path as the request. amount_paid is what actually arrived, so a wrong token can be named. redirect and amount formatting stay in the adapter; the core knows no HTTP.
PaymentStatus::MIN_SYNC_INTERVAL_SECONDS (5 s) names the throttle every status endpoint must apply before syncing the same order with the ledger again. The throttling itself and the last-sync timestamp live in the adapter — the core only keeps four platforms from picking four numbers.
New: SettlementPolicy::isWrongAsset() and creditedValue() (#4)
Four adapters, two derivations of the same rule: Magento and PrestaShop inferred "wrong token" from shortfall() === amountRequestedValue(), WooCommerce and Shopware compared issuer and currency themselves. Both are correct today, which is exactly what makes it worth fixing now — the same shape of duplication is how a hard-coded "RLUSD" survived in Shopware's USDC branch.
isWrongAsset() states the rule once; isSettled() and the shortfall calculation both ask it. creditedValue() is the number a payment page means by "received so far", and it credits "0" for a payment in the wrong asset however large it was. Credited plus outstanding is always exactly the requested amount. Adapters must not re-derive either.
What adapters do with this
Raise the constraint to ^0.5, then build the status endpoint on PaymentStatus::fromIntent()->toArray() plus redirect. Order per the handover: PrestaShop as the reference, then Shopware (drop XrplPaymentController::isWrongToken() and _loginRequired on the check route), then WooCommerce, then Magento. Authentication is a per-order secret, not a login — guest checkout is the rule.
0.4.0
The last of the three WooCommerce retrofit findings, and the only one that needs a schema migration in every adapter — which is why it was split out of 0.3.0 rather than shipped with it.
Breaking for adapters
| Change | What to do |
|---|---|
getLastSyncedLedgerIndex($destinationAccount, $network) |
Add a network column (VARCHAR(16) NOT NULL) to ledger_direct_xrpl_tx and an index on (destination, network, ledger_index); filter on both. Backfill existing rows or treat them as unknown. |
XrplTransaction |
Gains a leading $network constructor argument |
WooCommerce is live, so this goes through LedgerDirectInstall::maybe_upgrade() with DB_VERSION 3.
Why the cursor had to be scoped
getLastSyncedLedgerIndex() was a global MAX(ledger_index) over the whole transaction table, ported straight from Shopware, where one shop meant one account and one network. Two failures follow from that:
- A testnet reset stranded the cursor permanently. The testnet is reset periodically, which rewinds its ledger index. The dev database held rows from the previous epoch at ~45M while the live testnet was back at ~20.5M, so the cursor asked for a ledger range that does not exist and every sync failed with
lgrIdxsInvalid— for every order, indefinitely, until the rows were deleted by hand. - One mainnet row poisoned testnet forever. A mainnet row (index ~100M) pins the cursor above every testnet ledger, making a mainnet→testnet switch impossible without a database edit. The same applies across accounts: a merchant who changes receiving account resumed at the old account's cursor and never saw anything before it.
A ledger index only means anything within one network, so the cursor has to be scoped by network and account. The network is stored rather than derived: the CTID does encode a network id, but parsing it back out is far more work than a column.
Surviving a reset without touching the database
On lgrIdxsInvalid, and only when a cursor was actually sent, SyncService logs a PSR-3 warning and repeats the sync once with no ledger_index_min at all. findExistingHashes() keeps that from duplicating anything. Without a cursor the same error means something else entirely, so it propagates rather than doubling every failing sync — a guard that is not in the handover document and has its own test.
XrplRpcException now carries rippled's own error code, so that branch keys off a code rather than matching message text.
Rows that survive a reset are worthless but harmless: they are not deleted, just out-ranked by the resync.
Notes
123 tests, green on PHP 8.2 / 8.3 / 8.4 plus the lowest-dependency job. Contract details in INVARIANTS.md under Tables and "The testnet is not persistent".
0.3.0
Two of the three WooCommerce retrofit findings. Both fix bugs that mis-settled or crashed real orders; neither touches the database schema. The third (sync cursor per account and network, testnet-reset recovery) needs a migration in every adapter and ships separately as 0.4.0 — see #3.
Breaking for adapters
| Change | What to do |
|---|---|
findTransaction() → findTransactions() |
Return all rows for the destination/tag pair, ORDER BY ledger_index DESC, id DESC |
nextDestinationTagSequence() contract |
Seed the counter row with random_int(0, 2**31 - 1), never 0; the counter table must survive uninstall |
SyncService::findTransaction() is likewise replaced by findTransactions() plus findTransactionFor(PaymentIntent).
Choosing the paying transaction by asset class
A destination tag can carry more than one transaction. The old code took whichever row the primary key surfaced first, which in the dev shop meant a foreign 0.84 XRP payment settled order #133, and an old RLUSD payment handed to an XRP intent made withFulfillment() reject the shape — aborting the sync and leaving order #135 open forever with the real payment sitting there unexamined.
SyncService::findTransactionFor() now picks the newest candidate whose delivered amount matches the quote's asset class. A candidate in the right class but from the wrong issuer is deliberately kept rather than skipped: it is a real payment attempt, and SettlementPolicy is what declares it non-settling, so a shop can tell the customer their token was wrong instead of showing them nothing. Non-payments and unreadable "unavailable" amounts are skipped and logged, never a reason to abort a sync.
Random destination-tag starts
The permutation's multiplier and offset are public constants, so a counter starting at 0 produced the same tag sequence in every installation — sequence 0 is always 114729. Two shops on one receiving account issued identical tags and settled each other's orders. The counter now starts at random_int(0, 2**31 - 1), which leaves ~2.1 billion sequences, so the range still needs no wraparound and DestinationTagsExhaustedException stays reachable. DestinationTagService itself is unchanged — the permutation is a bijection over the whole range and does not care where the counter starts.
Also now contractual: ledger_direct_xrpl_destination_tag must survive uninstall. It is the only record that a tag was already issued.
Notes
WooCommerce 1.0.0 carries local workarounds for both findings (findTransactionsByTag(), findPaymentFor()); drop them in favour of the core versions when raising the constraint. 117 tests, green on PHP 8.2 / 8.3 / 8.4 plus the lowest-dependency job. Contract details in INVARIANTS.md under Settlement and Tables.
0.2.0
0.2.0 — Settlement policy
The decision whether a delivered amount pays for a quote now lives in the core, so every
LedgerDirect plugin calls an order paid under exactly the same conditions. Until now each
plugin decided on its own (Shopware in its payment handlers, Magento in a local AmountCheck).
Added
Core\Payment\SettlementPolicyisSettled(PaymentIntent): bool- Native asset (XRP):
amount_paid >= amount_requested × (1 − tolerance). Default tolerance
0.0015(0.15 %) — the quote is a float rounded to five places and wallets may shave
rounding on their side. Platforms may pass their own tolerance. - Issued currency (RLUSD, USDC, merchant tokens):
currencyandissuermust equal the
quoted ones andvalue >= requested value, compared as exact decimals, no tolerance.
A same-named token from another issuer never settles the order. - Overpayment settles; a
PaymentIntentwithoutamount_paidnever does.
- Native asset (XRP):
shortfall(PaymentIntent): ?string— what is still due as a plain decimal string:null
once settled, the full amount while nothing has arrived, and the full amount for a payment
in the wrong token (it does not count against the request). Meant for "X of Y received"
on a payment page.
PaymentIntent::amountRequestedValue()/amountPaidValue()— the plain decimal behind
either amount shape (float for the native asset,IssuedCurrencyAmountotherwise), without
trailing zeros or exponent notation.INVARIANTS.md, section "Settlement" — the rules above, and the line between the core
(the decision) and the platform adapter (invoice, order status, emails).
Notes
- Additive only; no existing behaviour changed.
- Decimal rendering deliberately avoids
BigDecimal::stripTrailingZeros(): brick/math renamed
it tostrippedOfTrailingZeros()within the range this package supports (^0.12 … ^0.20). - CI: PHP 8.2 / 8.3 / 8.4 and the lowest-dependencies job (brick/math 0.12) — green.
Upgrading
No changes required.