13 min readAug 9, 2026by jakub

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 updateStoreCreditEmailPreference GraphQL mutation both reported the new value — but nothing was written. The qoliber_storecredit_customer_settings table 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_id as 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:flush

No 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:compile

setup: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_at had 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 +100 expiring January, +50 expiring February and 80 spent, the January run debited 100 and the February run debited 150250 taken from an account that should have lost 70.

    Expiry now derives each lot's genuinely unspent remainder by FIFO allocation, records which lots it settled in the new qoliber_storecredit_expired_lot table, 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_shipping was 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 stamped UPDATE, 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 and storecredit:repair-captures now 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 took pending, so the repair command — which exists for exactly those rows — claimed nothing. Claimable statuses are now explicit: the cron takes pending, storecredit:repair-captures also takes failed. And because qoliber_storecredit_order.order_id is 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 under SELECT … 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_ledger for more than one expire_debit per account. Balances may have been debited more than once.
  • Refund-to-credit used? Compare credit-memo grand totals against qoliber_storecredit_creditmemo amounts; some customers may have received both cash and credit.
  • Partial invoicing with store credit? Check qoliber_storecredit_order.base_amount_invoiced against the sum of its qoliber_storecredit_invoice rows.
  • Admin-created orders? Look for orders carrying a store credit discount with no matching qoliber_storecredit_order row.

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/transactions was wired to TransactionRepositoryInterface::getList(), whose only parameter is $searchCriteria. Magento's ServiceInputProcessor binds URL segments to method parameters by name, so :customerId was silently discarded and the route applied no customer scoping at all — a caller holding Qoliber_StoreCredit::transaction_view could read every customer's ledger, and an empty searchCriteria returned all of it. The route now targets the new getListForCustomer(), 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, :customerId on the credit/debit routes and :orderId on refund-to-credit were 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 throw InputException when it contradicts the body. For refund-to-credit the 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_id filter and called getList() — the storefront My Store Credit page and the admin customer tab — now call getListForCustomer(). Same output; the guarantee moved out of each caller and into the repository.

Added

  • TransactionRepositoryInterface::getListForCustomer(int $customerId, SearchCriteriaInterface $searchCriteria).
  • GET /V1/storecredit/transactions for 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) and RefundToCreditManagementInterface::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 -l on 8.1/8.2/8.3/8.4/8.5, a Rector downgrade-to-8.1 floor guard (0 files), and PHPCompatibility testVersion 8.1-8.5 — all clean.
  • PHPStan (level 8) and PHPCS (Magento2) now cover all five suite modules, not just StoreCredit and StoreCreditGraphQl. Both are clean.
  • Two PHPStan findings in Block/Sales/Order/StoreCredit.php suppressed with a recorded rationale: they are false positives caused by Magento\Sales\Block\Order\Totals::getSource() being annotated @return Order while its Invoice\Totals / Creditmemo\Totals subclasses override it.
  • Standardised file headers across all PHP/XML/JS/PHTML/LESS/CSS files and aligned LICENSE.md with the current Qoliber Extensions User License.

Known issues

  • QuoteCreditManagement::doApply() still calls $quote->collectTotals() inside the DB transaction that holds a FOR UPDATE lock on the credit_quote row. 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's maxApplicable (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-run and --order-id options) that backfills missing qoliber_storecredit_invoice projection 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 up store_credit_amount via the afterGet plugin and the "Store Credit" line appears on re-printed PDFs of historical invoices.
  • Wired the new command into etc/di.xml alongside storecredit:repair-captures and storecredit:repair-refunds.

Upgrade

For a production environment that has the broken intermediate 1.0.1:

  1. composer update qoliber/store-credit (pulls 1.0.2).
  2. bin/magento setup:upgrade && bin/magento cache:flush.
  3. bin/magento storecredit:repair-invoices --dry-run to preview.
  4. bin/magento storecredit:repair-invoices to apply.
  5. 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 — parses sales.xml on 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 existing Model\Total\Invoice\StoreCreditTest could not (that test exercised collect() 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 reads credit_order; invoice context reads credit_invoice (verifies factory is NOT called for credit_order when the invoice row exists); pre-1.0.1 invoices fall back to credit_order when no invoice projection exists; nothing added when no credit is applied; apply_to_tax=true swaps 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: invoice grand_total went €59 → €54 (deduction applied); a row appeared in qoliber_storecredit_invoice with amount=5; credit_order.base_amount_invoiced moved 0 → 5; reloading the invoice through the repository populated store_credit_amount=5 on 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_ledger with 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 + ProcessFailedCaptures cron + RepairCapturesCommand.
  • Refund-failure recovery: a post-commit refund failure in CreditmemoServicePlugin::afterRefund (Magento's credit memo already committed) is queued to qoliber_storecredit_refund_queue and retried by the ProcessFailedRefunds cron (idempotent via the stored ledger key), with a storecredit:repair-refunds CLI 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 UPDATE row 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 UPDATE and 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); AdjustmentManagement rejects a target account that does not belong to the request customer.
  • transaction_posted event is dispatched only after the outermost transaction commits.
  • general/enabled is 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_Expr usage.

Notes

  • PHP 8.1–8.5, Magento 2.4.x / Mage-OS. PHPStan level 8, PSR-12.
Changelog — Qoliber Store Credit — Checkout & Shipping — Extensions | qoliber Docs