Changelog
Current version: 1.2.1
All notable changes to Qoliber Store Credit are documented here. The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
[1.2.1] - 2026-08-08
Fixed
-
A customer's email-notification preference was never saved. Turning "Notify me of balance changes via email" on or off appeared to work — the account page and the
updateStoreCreditEmailPreferenceGraphQL mutation both reported the new value — but nothing was written. Theqoliber_storecredit_customer_settingstable stayed empty for every customer, so the setting always fell back to its default and customers could neither opt in nor opt out.The resource model declared
customer_idas its identity field. That column is a natural primary key rather than an auto-increment surrogate, so a record that had never been written still reported an id, Magento treated every first save as an update, the statement matched no rows, and the save reported success. The resource now decides new-versus-existing from whether the row actually exists.No data was lost — the preference was never stored, so nothing needs repairing. Customers who previously set a preference will find it took effect from this release onward.
Added
- Playwright coverage for the GraphQL surface and for all five cron jobs. The GraphQL tests are what surfaced the defect above; the cron tests pin the FIFO expiry behaviour fixed in 1.2.0, including that a second run does not debit an already-settled lot.
Upgrade
bin/magento setup:upgrade
bin/magento cache:flushNo schema change and no setup:di:compile requirement in this release.
[1.2.0] - 2026-08-07
A correctness release. Every item below was found by review of shipped code, reproduced, and fixed with a regression test that fails against the previous version. Several move money incorrectly on 1.0.0–1.1.0, so read "Should I audit my data?" at the end of this entry.
Upgrade
Requires both, in this order:
bin/magento setup:upgrade
bin/magento setup:di:compilesetup:di:compile is not optional — several constructors gained arguments and a
stale generated interceptor fails with "Too few arguments to function
…::__construct()". Clearing generated/ has the same effect.
Verified end to end on MySQL 8.0.43 against a real 1.1.0 database: the upgrade completes in a single pass.
Fixed — money
-
Expiration could debit the same credit twice, and could expire credit the customer had already spent. The cron summed every positive ledger row whose
expires_athad passed, with no record of what an earlier run had handled, and nothing linked a debit to the credit it consumed. On an account with+100expiring January,+50expiring February and80spent, the January run debited100and the February run debited150—250taken from an account that should have lost70.Expiry now derives each lot's genuinely unspent remainder by FIFO allocation, records which lots it settled in the new
qoliber_storecredit_expired_lottable, and posts the debit and that record in one transaction. Allocation walks the ledger in order, so a lot that has already expired cannot absorb a spend made afterwards, and an expiry debit is never mistaken for a spend. -
Refunding to store credit could pay out twice. The credit-memo collector never reduced the memo's grand total while the refund plugin credited the wallet after Magento had already refunded the memo in full. A €50 memo sending €20 to store credit returned €50 to the payment method and €20 of credit — €70 out on a €50 memo. The mirror case blocked legitimate refunds, because the memo total still claimed cash the order could not return. The credited portion is now deducted from the memo total, capped at what the order captured and has not already had refunded, and the wallet receives exactly the capped figure.
This includes the case where the cap removes the request entirely. A zero-total memo legitimately clamps the whole amount away, and the resolver previously read that zero as "no figure was published" and fell back to the raw request — so a memo correctly paying out no cash still credited the wallet the full amount. Whether the figure is present now decides, not whether it is greater than zero.
-
Admin orders could be created underpaid. Reservation and capture ran after the order existed, in three separate commits, with failure written only to a log — and unlike the storefront there was no retry queue. Reservation moves before the order exists, so a shortfall is a validation error with nothing created; capture and projection now commit together, with a queue fallback.
-
A released or expired reservation still discounted the order. Placement checked only that a reservation key string was present. On a dead reservation the order was created with the discount, capture failed, and every retry failed identically — the reservation cannot come back. Both storefront and admin now verify the reservation still holds.
-
Partial invoices double-counted credit. A repository save fires both the invoice plugin and the save observer, and both delegate to the same persister. The projection was written once but the order-level invoiced counter was incremented twice, so a €30 invoice registered as €60 and later invoices measured their remaining credit against a total already spent on paper.
-
Store credit deducted more in display currency than in base when
apply_to_shippingwas off and the display currency differed from base — by exactly the shipping amount times the rate.
Fixed — durability
-
Retry queues could process the same record twice. Both crons selected
status='pending'and iterated, so concurrent workers collided. Claiming is now a single stampedUPDATE, with stale rows reclaimed after 15 minutes so a dead worker does not strand its batch. Both queues gain uniqueness constraints they lacked. -
Capture recovery could stick permanently. When capture succeeded but the projection save failed, the reservation was consumed while retries kept calling
capture()— which throws for a non-active reservation, forever. Both the cron andstorecredit:repair-capturesnow detect the existing ledger capture and write only the missing projection. Records already in this state are repaired by the next cron run.Two things were needed to make that reachable in practice. A record that exhausts its retries is marked
failed, and the shared claimer only ever tookpending, so the repair command — which exists for exactly those rows — claimed nothing. Claimable statuses are now explicit: the cron takespending,storecredit:repair-capturesalso takesfailed. And becauseqoliber_storecredit_order.order_idis unique, a record can survive with its projection already written; both paths now check before inserting rather than failing forever on the rows most needing to close. -
Reconciliation could overwrite a concurrent adjustment.
rebuild()computed the sums and then wrote, with no transaction and no row lock, and is reachable over REST. Each account is now rebuilt underSELECT … FOR UPDATE.
Changed — customer deletion
Deleting a customer previously cascaded customer_entity → account → the entire ledger, destroying the audit trail and orphaning sales projections. Financial history
is now retained while the personal identifier is removed: the account is detached and
closed, the ledger's denormalised customer id is cleared, and ledger → account is
NO ACTION so an account carrying history cannot be deleted at all.
Existing rows are untouched — this changes future deletions only.
Should I audit my data?
If you ran 1.0.0–1.1.0 in production:
- Expiration enabled? Check
qoliber_storecredit_ledgerfor more than oneexpire_debitper account. Balances may have been debited more than once. - Refund-to-credit used? Compare credit-memo grand totals against
qoliber_storecredit_creditmemoamounts; some customers may have received both cash and credit. - Partial invoicing with store credit? Check
qoliber_storecredit_order.base_amount_invoicedagainst the sum of itsqoliber_storecredit_invoicerows. - Admin-created orders? Look for orders carrying a store credit discount with no
matching
qoliber_storecredit_orderrow.
bin/magento storecredit:reconcile reports drift between the ledger and account
balances without changing anything (add -a <id> for a single account).
[1.1.0] - 2026-08-01
Security
- Customer-scoped transaction route returned the entire ledger.
GET /V1/storecredit/customers/:customerId/transactionswas wired toTransactionRepositoryInterface::getList(), whose only parameter is$searchCriteria. Magento'sServiceInputProcessorbinds URL segments to method parameters by name, so:customerIdwas silently discarded and the route applied no customer scoping at all — a caller holdingQoliber_StoreCredit::transaction_viewcould read every customer's ledger, and an emptysearchCriteriareturned all of it. The route now targets the newgetListForCustomer(), which pins the customer filter as its own WHERE clause before the search criteria are processed, so a caller-supplied filter can only narrow the result set, never widen it. - URL/body disagreement is now rejected on the adjustment routes. For the
same parameter-binding reason,
:customerIdon thecredit/debitroutes and:orderIdonrefund-to-creditwere discarded, leaving the request body alone to decide whose balance moved — so the URL, and any audit log or WAF rule keyed on it, could describe an adjustment that never happened. All three now take the route value and throwInputExceptionwhen it contradicts the body. Forrefund-to-creditthe check is against the order the credit memo actually belongs to, so a memo from a different order cannot be pushed through a URL claiming otherwise. - Both blocks that hand-built a
customer_idfilter and calledgetList()— the storefront My Store Credit page and the admin customer tab — now callgetListForCustomer(). Same output; the guarantee moved out of each caller and into the repository.
Added
TransactionRepositoryInterface::getListForCustomer(int $customerId, SearchCriteriaInterface $searchCriteria).GET /V1/storecredit/transactionsfor deliberately unscoped cross-customer back-office reporting, so the unscoped capability is explicit rather than an accident of routing.- Optional route-bound parameters on
AdjustmentManagementInterface::credit()/debit()(?int $customerId = null) andRefundToCreditManagementInterface::refund()(?int $orderId = null). Optional, so existing callers are unaffected. - 10 unit tests pinning the scoping contract, including one asserting the customer filter is applied before the collection processor — the ordering is the entire security property.
Fixed
- PHP 8.5 compatibility: removed five
ReflectionProperty/Method::setAccessible()calls, deprecated in 8.5 and a no-op since 8.1. Unit-suite errors on 8.5 went from 9 to 3; the 3 that remain originate in Mage-OS core(double)casts, not in this module.
Internal
- Verified across the full supported range:
php -lon 8.1/8.2/8.3/8.4/8.5, a Rector downgrade-to-8.1 floor guard (0 files), and PHPCompatibilitytestVersion 8.1-8.5— all clean. - PHPStan (level 8) and PHPCS (Magento2) now cover all five suite modules, not
just
StoreCreditandStoreCreditGraphQl. Both are clean. - Two PHPStan findings in
Block/Sales/Order/StoreCredit.phpsuppressed with a recorded rationale: they are false positives caused byMagento\Sales\Block\Order\Totals::getSource()being annotated@return Orderwhile itsInvoice\Totals/Creditmemo\Totalssubclasses override it. - Standardised file headers across all PHP/XML/JS/PHTML/LESS/CSS files and
aligned
LICENSE.mdwith the current Qoliber Extensions User License.
Known issues
QuoteCreditManagement::doApply()still calls$quote->collectTotals()inside the DB transaction that holds aFOR UPDATElock on thecredit_quoterow. A third-party tax collector doing an HTTP round-trip will hold that lock for the duration of the call. Not changed here: the lock deliberately serialises the clear-then-recollect, and the credit cap depends on the collector'smaxApplicable(subtotal + shipping − discount, ±tax by config) rather than the grand total, so there is no equivalent arithmetic shortcut. Restructuring it needs an integration test for concurrent apply first.
[1.0.2] - 2026-06-20
Re-release of 1.0.1 with a new version number, plus a CLI backfill tool for production environments that already pulled the broken intermediate 1.0.1 (see below).
Why a new version
1.0.1 was re-tagged three times during the original release as more
gaps in the invoice/PDF code-path surfaced — Composer/Private Packagist
cache packages by version number, not by SHA, so any consumer that
pulled 1.0.1 during the first window has a cached intermediate that
won't be refreshed by composer update while the version string stays
the same. Bumping to 1.0.2 (no code changes vs the final 1.0.1)
forces every cache to fetch the complete fix. If you're on a fresh
install, 1.0.2 == 1.0.1 plus the new repair CLI.
Added
- New CLI command
bin/magento storecredit:repair-invoices(with--dry-runand--order-idoptions) that backfills missingqoliber_storecredit_invoiceprojection rows for invoices generated before the legacy-save-path observer existed. Heuristic per invoice: reverse the collector math,(subtotal + shipping + tax − discount) − grand_total= credit applied, capped at the remaining captured credit on the order. Sub-cent rounding noise is skipped. After running, the per-invoice PDF renderer picks upstore_credit_amountvia theafterGetplugin and the "Store Credit" line appears on re-printed PDFs of historical invoices. - Wired the new command into
etc/di.xmlalongsidestorecredit:repair-capturesandstorecredit:repair-refunds.
Upgrade
For a production environment that has the broken intermediate 1.0.1:
composer update qoliber/store-credit(pulls1.0.2).bin/magento setup:upgrade && bin/magento cache:flush.bin/magento storecredit:repair-invoices --dry-runto preview.bin/magento storecredit:repair-invoicesto apply.- Re-print any historical invoice PDF to verify the "Store Credit" line now renders.
[1.0.1] - 2026-06-16
The 1.0.0 invoice/credit-memo totals were broken in four independent ways that all surfaced from one customer report ("Eindtotaal is wrong on the admin View Invoice page and on the PDF"). This release fixes all four.
Fixed
(1) Sales totals collector wiring — invoice and credit-memo grand total
were not reduced by applied store credit. etc/sales.xml registered the
invoice and creditmemo total collectors under <section name="invoice">
and <section name="creditmemo">, but Magento's
Magento\Sales\Model\Order\Invoice\Config and
Magento\Sales\Model\Order\Creditmemo\Config explicitly read from sections
named order_invoice and order_creditmemo. Our collectors lived in dead
sections — they were never instantiated at totals-collection time, so
invoice->grand_total stayed at subtotal + shipping with no credit
deduction. The admin/customer "View Invoice" page still rendered a
"Store Credit: -€X" line (added by the display-only initTotals() block),
which made the bug look cosmetic when it was actually structural.
(2) PDF invoice missing the "Store Credit" line. Magento renders PDFs
through Magento\Sales\Model\Order\Pdf\Config which reads etc/pdf.xml,
not etc/sales.xml. Without a pdf.xml entry, the PDF showed the (now
correct) Grand Total but no line item explaining the deduction — the
customer saw subtotal + shipping ≠ grand_total with no explanation.
Added etc/pdf.xml registering a new Model\Order\Pdf\Total\StoreCredit
renderer at sort_order 450 (between shipping and grand total). The
renderer hides itself on credit-memo PDFs because store_credit_amount
is only populated on invoices — credit-memo refund-to-credit is a
separate semantic that doesn't reduce credit-memo grand total.
(3) On-screen totals block always read the order-level credit even on
invoice/credit-memo views. Block\Sales\Order\StoreCredit::initTotals()
always pulled creditOrder.base_amount. For the common "one invoice per
order" flow this happens to match what was deducted from that invoice,
which is why the bug looked benign — but for partial invoicing (multiple
invoices per order) every invoice would display the same full order-level
credit instead of the per-invoice slice that actually applied. Rewrote
the block to read credit_invoice.amount when the parent totals block's
source is a Magento\Sales\Model\Order\Invoice, falling back to
credit_order for the order view and for pre-1.0.1 legacy invoices that
predate the per-invoice projection.
(4) Per-invoice projection row was never persisted when admin generated
an invoice. Magento's Magento\Sales\Controller\Adminhtml\Order\Invoice\Save
saves through Magento\Framework\DB\Transaction — the legacy save path —
which bypasses Magento\Sales\Api\InvoiceRepositoryInterface entirely.
The existing Plugin\Sales\InvoiceRepositoryPlugin::afterSave was
registered on the repository interface and therefore never fired for
admin-generated invoices, so the qoliber_storecredit_invoice projection
row was missing and credit_order.base_amount_invoiced was never bumped.
Symptom: even with fix (1) applied, the PDF had nothing to render (its
source_field=store_credit_amount is populated on the invoice entity by
the repository's afterGet plugin from the projection row, which didn't
exist), and a second invoice on the same order would re-apply the full
credit because the order-level base_amount_invoiced counter stayed at
zero — a real data-integrity hole. Extracted the persistence logic into
a shared Model/Sales/Persistence/InvoiceCreditPersister service and
added an observer on sales_order_invoice_save_after that delegates to
it; that event fires from the invoice model's _afterSave, which runs
on BOTH the Transaction and Repository save paths. The repository plugin
now also delegates to the same service so both paths share one
idempotent persistence routine (row creation guarded by an existing-row
check, order-level invoiced counter capped at the original captured
amount).
Tests added
Test/Unit/Etc/SalesXmlSectionNamesTest.php— parsessales.xmlon disk, pins the required section names (order_invoice,order_creditmemo) with their expected collector classes, and asserts the wrong old names (invoice,creditmemo) are absent. Catches the section-name footgun that the existingModel\Total\Invoice\StoreCreditTestcould not (that test exercisedcollect()directly — proof of math does not prove the collector is wired in).Test/Unit/Model/Order/Pdf/Total/StoreCreditTest.php— 4 cases for the PDF renderer: hidden when amount is zero, renders as a negative line when credit applied, naturally hides on credit-memo PDFs, font-size default.Test/Unit/Block/Sales/Order/StoreCreditTest.php— 6 cases for the context-aware block: order context readscredit_order; invoice context readscredit_invoice(verifies factory is NOT called forcredit_orderwhen the invoice row exists); pre-1.0.1 invoices fall back tocredit_orderwhen no invoice projection exists; nothing added when no credit is applied;apply_to_tax=trueswaps the row position; no-op when the parent block is not a totals block.Test/Unit/Observer/PersistInvoiceCreditOnSaveTest.php— 3 cases for the observer that closes the legacy-save-path hole: delegates to the persister when the event carries an invoice; silent no-op when the event has no invoice or a non-invoice payload.
Total base-module unit count: 187 (up from 170), all passing.
Runtime end-to-end verification
Drove an actual invoice generation via Magento\Sales\Model\Service\InvoiceService
Magento\Framework\DB\Transaction(mirrors the admin Generate Invoice controller exactly) for an order with €5 store credit applied. Result: invoicegrand_totalwent €59 → €54 (deduction applied); a row appeared inqoliber_storecredit_invoicewithamount=5;credit_order.base_amount_invoicedmoved 0 → 5; reloading the invoice through the repository populatedstore_credit_amount=5on the entity; rendering the PDF produced a "Store Credit: -€5.00" line between Shipping & Handling and Grand Total as designed.
Operator note
Existing invoices/credit-memos that were created under 1.0.0 still have
the broken grand_total value persisted; the fix only corrects new
invoices/credit-memos created from 1.0.1 onwards. If broken historical
invoices exist in production data, fix them with a one-shot SQL:
subtract the linked qoliber_storecredit_credit_invoice.amount from
sales_invoice.grand_total and .base_grand_total per invoice_id.
A bin/magento storecredit:repair-invoices CLI can be added on request.
1.0.0 - 2026-05-24
Added
- Ledger-first store credit wallet: append-only
qoliber_storecredit_ledgerwith denormalised account balances (posted,reserved,version) and DB-enforced idempotency (idempotency_key) and reservation uniqueness. - Reservation → capture lifecycle for checkout, with an FX-rate snapshot at capture
and banker's-rounding (
MoneyRounder) per-item distribution. - Refund-to-credit with an over-refund cap, plus invoice/credit-memo total collectors.
- Admin: customer store-credit tab (balance, adjust, transaction history with linked order / credit memo), balance grid, transaction grid, CSV import/export.
- Customer account store-credit page + FPC-safe header balance widget (customer-data section).
- REST (
/V1/storecredit/*) and console tooling (export, import, reconcile, recalculate, repair-captures, merge-balances). - Capture-failure recovery:
qoliber_storecredit_capture_queue+ProcessFailedCapturescron +RepairCapturesCommand. - Refund-failure recovery: a post-commit refund failure in
CreditmemoServicePlugin::afterRefund(Magento's credit memo already committed) is queued toqoliber_storecredit_refund_queueand retried by theProcessFailedRefundscron (idempotent via the stored ledger key), with astorecredit:repair-refundsCLI for manual recovery. - Reconciliation, health-check, expiration (TTL) + expiry-warning crons, reason-code policy, eligibility/scope/currency strategies, webhook dispatch.
- Zero-total payment method (
qoliber_storecredit_zero_total) auto-selected when store credit covers the full order; the previously-selected method is restored on removal.
Fixed / Hardened (audit)
- Refund-to-credit is fully transactional (ledger + projection + cap bump commit/roll
back together); the over-refund cap is re-validated under a
FOR UPDATErow lock; replays dedup on the stored ledger key regardless of the caller's idempotency key. - Concurrent capture and release re-read the reservation row
FOR UPDATEand re-validate status inside the transaction (no double-spend / negative reserved balance). - Refund / cancel-restore credit the exact captured account in the wallet's currency
(not the order display currency);
AdjustmentManagementrejects a target account that does not belong to the request customer. transaction_postedevent is dispatched only after the outermost transaction commits.general/enabledis a real kill-switch (gates apply + the quote totals collector).- Customer account store-credit page is no longer FPC-cached (private balance was being depersonalised to $0 and cached).
- PHP 8.4 implicit-nullable parameters, removed
Zend_Db_Exprusage.
Notes
- PHP 8.1–8.5, Magento 2.4.x / Mage-OS. PHPStan level 8, PSR-12.