Repository navigation
Releases: glueful/commerce
Release list
v1.14.0
[1.14.0] - 2026-10-08 — Product list queries
Theme: a product list can be narrowed, ordered and decorated the way a storefront grid needs.
Category and tag filters take lists (any of them, both groups ANDed); on-sale and in-stock
filters join them; listActive() takes an order; and every category and tag of a page of products
loads in one query each. Breaking for direct callers of ResolvedProductFilters: the single
categoryUuid / tagUuid arguments are now categoryUuids / tagUuids lists.
Changed
ResolvedProductFilterstakes lists (breaking):categoryUuidsandtagUuidsare any-of
lists, ANDed with each other; the singlecategoryUuid/tagUuidarguments are gone. The
storefront product list passes one-item lists; its API is unchanged.
Added
- On sale and in stock filters:
ResolvedProductFilters(onSale: true)keeps products with an
active variant priced below its compare-at price;inStock: truekeeps products with an active
variant that is untracked, has no stock row, or is tracked above zero. - Product list order:
listActive()takes aProductSort—newest(default),price_asc,
price_desc(by the lowest active variant price; unpriced products last) andname. - Batch taxonomy projections:
CategoryRepository::categoryProjectionsForProducts()and
TagRepository::tagProjectionsForProducts()— every category or tag of a list of products in
one query.
v1.13.0
[1.13.0] - 2026-08-17
Added
- Declares the Glueful schema manifest (migration descriptors, requires.extensions, structural
verifier); requires framework >=1.79.0 for schema-on-enable participation. Migrations are now
registered by the manifest, not by provider boot.
v1.12.0 — Draft Artifact
[1.12.0] - 2026-08-14 — Draft Artifact Lifecycle & Settlement Idempotency
Theme: an order artifact that never touched money can finally be deleted, and a paid-order
race can finally lose without crashing. A draft artifact — a walk-in order canceled before it
ever touched checkout (order_number IS NULL and status = 'canceled') — is money-free by
construction, so this release adds the one guarded mechanism to remove it: an operator endpoint
and an aged sweep, both driven by the same structural predicate, transactional across every child
row, and deliberately the one unaudited destructive operation in the engine. Alongside it: a
vanished order now reports a typed OrderNotFoundException instead of a bare RuntimeException,
a mark-paid on a draft uuid 404s instead of leaking a 409, and a lost paid-CAS race concedes
idempotently (200) instead of surfacing a bare 500 — the loser's own transaction has rolled back,
a fresh re-read proving somebody else reached paid, with the settlement handler routing the
concession to the same late-payment path a slower read would have taken anyway.
Added
DELETE /orders/{uuid}/artifact— draft-artifact deletion (catalog key
orders.artifact.destroy,managemode, the 119th admin route). A draft artifact is an
order row that was canceled while it was still a draft:order_number IS NULLand
status = 'canceled'. Until now nothing in the engine ever removed one, so every abandoned
walk-in draft accumulated forever and stayed visible in the admin orders list. The endpoint
hard-deletes the row together with itscommerce_order_lines,commerce_order_eventsand
commerce_order_draft_attemptsin ONE transaction. Refusals are a closed set: a typed 409
order_not_deletable(carrying the observedstatus) for every other row the tenant owns —
including an ACTIVE draft, which must be canceled first so a mis-click cannot destroy work in
progress — and a non-revealing 404, byte-identical for unknown and cross-tenant uuids. The
lookup is deliberately draft-INCLUSIVE, unlike every sibling admin order endpoint, so a draft
gets the 409 that names its remedy instead of a 404 that denies it exists.DraftCleanupService::purgeStale()andcommerce.orders.draft_purge_days(default 30,
clamped 1–365) — the aged sweep behind the SAME guarded delete. Canceled numberless artifacts
whoseCOALESCE(updated_at, created_at)is older than the window are destroyed in batches, and
it is wired as a THIRD independent sweep on the existingcommerce:orders:expiretick, so
hosts get purging with no new cron obligation. Cancellation runs before purging and stamps
updated_at, so a draft is never destroyed on the tick that canceled it. Per-row
compare-and-set, so overlapping sweeps double-delete nothing.OrderScope::deletableArtifactSql()/isDeletableArtifact()— the ONE artifact
predicate, in its SQL and PHP forms, beside the existing draft predicate. The conjunction is a
structural proof the row never touched money (only finalize/checkout allocate a number, and
every financial child — payments, invoices, stock claims, payment links, refunds, marketplace
rows — is created at or after that allocation), which is the only reason a hard delete is legal
at all. The guard lives in theDELETE's ownWHEREclause and runs FIRST, so a caller's
precheck is never the authority: a row that stops being an artifact mid-request is refused by
the database, the endpoint re-reads, and answers 404 or 409 from current truth. Proven against
a genuinely concurrent second OS process on real PostgreSQL, and falsifiable — dropping the
predicate destroys a numbered order and its lines in that lane.line_counton every admin DRAFT response — the drafts LISTING previously projected each
row with no lines at all, so a client could only render a confidently wrong "0 items" per draft.
DraftOrderService::paginate()now hydrates a real count for the whole page in ONE grouped,
tenant-constrained query (OrderRepository::lineCountsForOrders()) — never a per-row lookup and
never the line payload multiplied by the page size — andDraftOrderProjectionpublishes it as a
derived wire field on the listing, the detail, and every mutation response alike.linesitself
stays unhydrated on the listing by design;DraftOrderProjection::FIELDSis unchanged (it is the
commerce_orderscolumn whitelist, andline_countis not a column).OrderNotFoundException—OrderRepository::transition()reported a vanished (unknown or
cross-tenant) order as a bare\RuntimeExceptionseparable only by its message, so every caller
logged an ordinary "no such uuid" as a 500. It is now typed (still a\RuntimeException, still
the same genericOrder not found.message, so nothing that caught one or read one breaks).ConcurrentOrderTransitionException— the LOSER oftransition()'s compare-and-set is now
separable from an outright illegal transition. Still a\DomainExceptioncarrying the identical
message, so everycatch (\DomainException) -> 409caller is unaffected; it additionally carries
from/toand theobservedstatus read back immediately after the failed CAS.
Changed
- Draft-artifact deletion is the one deliberately UNAUDITED destructive operation in this
engine. It records nocommerce_order_eventsrow — one would reference an order that no
longer exists, andeventsForOrder()joins throughcommerce_orders, so it would be
unreadable the moment it was written. In its place acommerce.orders.artifact_deleted
app-log line records actor, uuid, tenant and reason, and no customer PII (the artifact's
name, email and phone die with the row rather than being copied into a log on the way out).
The posture is acceptable for exactly one class of row — one the database has just proven never
touched money — which is why the guard is a SQL predicate and not a caller's promise. Hosts
that need a durable operator trail should record it from their own admin layer, since the
engine has nowhere left to put one. commerce:orders:expirenow runs THREE sweeps, not two — the artifact purge joins order
expiry and draft cancellation, isolated from both (each in its own try/catch; a throwing
sibling never suspends it, and any failure still surfaces as a non-zero exit).OrderPaymentService::markPaid()answers a lost paid race idempotently and now returns
bool(trueiff THIS call performed thepending_payment -> paidtransition). When two
settlement paths race — materially more likely since the webhook lane — the loser used to
surface as a bare 500 even though the exact end state it asked for had just been reached. It now
concedes: its own transaction has rolled back, so a fresh tenant-scoped re-read showingpaid
proves somebody else got there, and the call writes nothing, posts nothing and dispatches
nothing. Any other observed status still rethrows unchanged. Proven under genuine two-connection
PostgreSQL contention.OrderPaymentConfirmationHandlerroutes a conceded settlement torejectLatePayment()—
byte-identical to what it would have done had its status read landed a moment later, so one
real-world situation has one outcome whatever the timing, and a late provider payment stays as
discoverable as before.AdminOrderController::markPaid()runs the draft-blindorder()precheck every sibling
order endpoint already ran: a draft (or unknown, or cross-tenant) uuid is now the same
non-revealing 404 instead of the 409 the transition CAS used to return. A conceded settlement is
answered 200 with the paid order, never 409.
Upgrade Notes
commerce:orders:expirestarts deleting canceled draft artifacts after this upgrade. The
cron's new third sweep (DraftCleanupService::purgeStale()) permanently removes any order row
matchingorder_number IS NULL AND status = 'canceled'whoseCOALESCE(updated_at, created_at)
is older thancommerce.orders.draft_purge_days(default 30, clamped 1–365). There is no
disable value by design — a numberless canceled row is structurally proven to have never touched
money (only finalize/checkout allocate a number, and every financial child is created at or after
that allocation), which is the whole reason the sweep is safe to run unattended. Hosts with a
retention requirement should raiseCOMMERCE_ORDER_DRAFT_PURGE_DAYS(max 365) before
upgrading, not after.- Hosts mounting the admin route catalog must allowlist
orders.artifact.destroy.
AdminMountProfile::restricted()resolves allowlists key-by-key, and an unlisted key is simply
not mounted (silently, not an error) — the newDELETE /orders/{uuid}/artifactroute, and any
UI action wired to it, will 404 at the host router until the key is added. OrderPaymentService::markPaid()signature widenedvoid→bool. The method isfinal
and not part of any interface, so every existing call site that ignores the return value is
unaffected. The bool reports whether this call performed thepending_payment → paid
transition (falsemeans it conceded to a settlement that already won the race).
Behavior changes (operator-visible)
- Marking a draft order paid now answers a non-revealing 404 (previously a 409 that
incidentally confirmed the uuid was a real, if unpayable, order). - Losing a paid compare-and-set now answers idempotently — 200 with the already-paid order
— instead of a bare 500; the losing settlement's confirmation handling routes to
rejectLatePayment(), the same outcome a slower read would have produced anyway. - A transition against a vanished (unknown or cross-tenant) order now th...
v1.11.0 - Payment Links & Session-Exposure Guarded Cancellation
[1.11.0] - 2026-08-12 — Payment Links & Session-Exposure Guarded Cancellation
Theme: a payment link is a bearer credential, and once it has shown a provider a live
session, cancellation stops being automatic. Admins can now mint a tokenized public payment
link for any pending_payment order and hand it to a payer with no login; the raw token exists
nowhere but the mint response and the minted URL, every custody row stores only its SHA-256
hash, and initiating checkout from a public link is a two-phase, lock-disciplined operation with
its own per-link hourly ceiling. The moment any link on an order has ever exposed a live provider
session, the new PaymentSessionExposureGuard becomes the sole authority over that order's
non-draft cancellation — the sweep backs off entirely and an operator must explicitly accept the
late-payment risk, audited in the same transaction as the override.
Added
commerce_payment_linkstable (migration 023) — hashed token custody: every row stores a
SHA-256token_hash, never a raw token; the raw value is engine-egress-limited tomint()'s
return andmintPublic()'s composed URL, pinned by a reflection ratchet over every public
signature in the custody path.statusandtoken_hashare NOT NULL with no standing default,
so a writer that omits either fails loudly rather than minting a credential silently. Indexed by
UNIQUE(tenant_uuid, token_hash), UNIQUE(tenant_uuid, uuid)(row identity), plus a
(tenant_uuid, provider_session_issued_at, order_uuid)prefilter index for the expiry/cancel
guard scan. No partial-unique index anywhere — two ACTIVE rows can coexist for one order at the
database level; the service layer is the one-active-per-order authority.PaymentLinkService— the mint/resolve/initiate authority.mint()/mintPublic()run
inside one transaction that locks the order before the link, revoking any prior active link so
exactly one survives;mintPublic()'s host seam (PaymentLinkPublicUrlProvider) composes and
validates the public URL before anything is persisted, so a misconfigured host mints nothing.
resolveByToken()is the anonymous, unlocked read: a revoked link resolves to a
content-redacted, state-only view (status/expiry/exposure only — no order number, lines,
currency, or totals); every other terminal state (expired, consumed, canceled, refunded) still
resolves in full, since the payer holding that link needs to understand what happened to their
bill. Every throwing path — mint refusals, resolve, stale matches, invalid provider URLs — runs
with the raw token scrubbed from the parameter list or held only as a local, never as a
live stack-frame argument, so a backtrace reaching a log or a reviewer cannot leak it.- Two-phase
initiateByToken()— Phase A claims the order/link locks, revalidates every
predicate, and claims the link's hourly initiation window (default 10/hour, config-clamped
1-100) all inside one committed transaction; the provider round trip that follows holds no
transaction and no row lock; Phase B re-locks and rechecks every predicate against a fresh
clock read before trusting the collector's answer and stampingprovider_session_issued_at.
CallinginitiateByToken()from inside a caller-owned transaction throws a dedicated
LogicExceptionrather than running unguarded. Per-link TTL defaults to 7 days
(config-clamped 1-30). Every typed refusal (unknown/expired/revoked/superseded token, rate
limit, unavailable return URL, untrusted or missing checkout URL, collector failure) carries no
URL and quotes neither the token nor a provider message. PaymentSessionExposureGuard— the single authority over non-draft order cancellation.
Given a locked order, it decides one of: no relevant link (ordinary cancellation proceeds); an
active, unexpired, never-initiated link (blocks the automatic sweep, but an operator may still
cancel plainly); or a link that has ever issued a provider session, whatever its current
status (blocks automatic cancellation entirely — an operator may only override by acknowledging
accept_late_payment_risk=true, audited aspayment_session_risk_acceptedin the same
transaction as the cancellation). The exposure sweep-exclusion prefilter is a lease-aware
addition toExpiryService; the guard itself re-decides inside each order's own transaction, so
the prefilter is never mistaken for authority.- Admin catalog —
orders.payment_link.store/.destroy/.show(manage mode,
POST|DELETE|GET /orders/{uuid}/payment-link).storemints viamintPublic()only, never the
raw-token-returningmint();showreads via a new token-freePaymentLinkService::currentLink()
and reports the current exposure decision;destroyrevokes idempotently by order. Refusals map
through a closed, publicly documented error-code table; any unmapped throwable — driver failure,
the transaction guard above — renders a bodiless 500, never a message or trace.
Changed
- Cancellation now runs the exposure guard before stock release on every guarded authority
(AdminOrderController::cancel,ExpiryService's sweep); an inventory ratchet test pins the
exact set of code paths that transition an order tocanceledor release its stock, so a future
authority cannot be added without also wiring the guard. CheckoutService::paymentReturnMetadata()now shares the sameSupport\HttpsUrl::isAbsoluteHttps()
definition the payment-link return-URL seam uses, removing a duplicate inline validation
predicate; behavior is unchanged except that an uppercaseHTTPS://scheme, previously
rejected, is now correctly accepted per RFC 3986.
Host obligations (Thallo and any embedding host)
- Bind
Contracts\PaymentLinkPublicUrlProviderandContracts\PaymentLinkReturnUrlProvider—
the engine defaults are typed-unavailable, so minting and initiation refuse with a typed 503
until a host supplies its canonical-origin URLs. - Carry route-level (IP/route) rate limiting on every public token-bearing surface. The engine's
per-link hourly ceiling bounds how often one known link can open a checkout; it cannot bound
anonymous enumeration of unknown tokens, which never reach a link to count against. - Pin
zend.exception_ignore_args=Onin deployment — defense in depth alongside the engine's own
stack-frame scrubbing of the raw token. - Map any throwable from
initiateByToken()that is not a typedPaymentLinkExceptionto a
bodiless 500; the typed set covers payer-facing outcomes only. - Requires payvia >= 2.6 semantics (ensure-live session renewal) for full payment-link
behavior in production, but thePaymentCollectorcontract consumed here is unchanged from
2.5, so nocomposer.jsonconstraint moves in this release — every collector failure mode,
including a 2.6 renewal refusal, is caught and surfaced as the typedcheckout_initiation_failed
refusal rather than an uncaught exception.
v1.10.0 - Admin Draft Orders & Walk-In Finalization
[1.10.0] - 2026-08-10 — Admin Draft Orders & Walk-In Finalization
Theme: an admin-born order that acquires its invariants at finalize. Admins can now build
draft orders (walk-in-first: every identity field optional, no placeholder emails, no guest
credential) and finalize them through a single authority that re-resolves the catalog, recalculates
every total, claims stock atomically, allocates the order number, and transitions to
pending_payment under durable idempotency — while drafts remain structurally invisible to every
pre-existing storefront, admin, report, mail, webhook, and reconciliation surface.
Added
draftorder status —draft → [pending_payment, canceled]in the state machine; BOTH pairs
are dedicated-path-only:OrderRepository::transition()refuses every draft transition;
finalization goes through the new CASfinalizeDraftTransition(), cancellation through
DraftCleanupService::cancelDraft()(draft-specific audit rows —draft_created,
draft_canceled,draft_expired— never lifecycle events).- Admin draft API (
orders.drafts.*catalog entries, manage mode): create / list / show /
update (customer block, fulfillment mode, addresses, live-quoted shipping method, discount code) /
line add-update-remove / recalculate (the explicit drift-acceptance operation) / cancel /
finalize. Every mutation CAS-incrementsdraft_revision; stale writes are typed conflicts. DraftFinalizationService— tenant-safe preflight (non-revealing 404, zero attempt-ledger
writes), then one transaction: idempotency claim (commerce_order_draft_attempts,
X-Idempotency-Key+ SHA-256 request fingerprint; replay returns the finalized order, mismatch
409, concurrent keys one winner), status/revision/currency conflicts, per-line re-resolution
(price/addon drift, availability, digital, marketplace, per-line variant-currency guard, delivery
address requirement), server-side recalculation, atomic stock claim + movements, savepoint-safe
order-number allocation, snapshot replacement on stable line uuids, discount consumption
(anonymous identity rules), CAS transition, attempt completion —OrderPlaceddispatches only
after commit, carrying the neworiginfield.- Walk-in order schema — nullable
order_number/email/guest_token_hash; new
phone_normalized(strict E.164) +phone_display,customer_name,origin
(storefront|admin, backfilledstorefront),fulfillment_mode(in_store|delivery,
backfilleddeliveryas conservative compatibility),draft_revision; new
commerce_order_draft_attemptsledger (tenant purge/adoption registered). PurchasableLineResolver— the shared variant/availability/addon pricing authority behind
both storefront checkout and draft mutations, with explicit persisted-snapshot vs
current-selections addon policies and per-linesellerUuidexposure.OrderFulfillmentService— non-partitioned fulfillment extracted from the admin controller
(owns the transaction and exactly-onceOrderFulfilleddispatch) as a peer of
OrderPaymentService::markPaid().- Admin product search eligibility —
admin_draft_eligible+ closed
admin_draft_ineligible_reason(digital|marketplace|unavailable), one reason authority shared
with the draft line endpoints. - Bounded draft cleanup —
commerce.orders.draft_ttl_days(default 30), batched idempotent CAS
cancellation on the orders-expire cron, isolated from the payment-expiry sweep. commerce.order_confirmationtoggle governing admin-origin order-placed mail; the mail
listener now refuses null/blank recipient emails across every lifecycle template.
Fixed
- Storefront wire-projection leak —
show/mineand the checkout placement response now pass
through a closedStorefrontOrderProjectionallowlist (guest_token_hash,tenant_uuid,
revision counters and friends can no longer serialize), pinned by sentinel-seeding ratchet tests. OrderNumberGenerator— the competing-insert retry is savepoint-isolated so a caught unique
violation can no longer poison the enclosing PostgreSQL transaction.commerce_stock.quantity/tracked— backfilled (0/false, behavior-preserving for the
tracked/quantity read paths; NULL integrity-signal rows are deliberately healed) and now NOT NULL.- Customer aggregation and guest linking no longer read draft rows (the one genuine
pre-existing cross-status leak).
v1.9.1 - Order Payable Constant & Invoice Currency Exponent
[1.9.1] - 2026-08-09 — Order Payable Constant & Invoice Currency Exponent
Additive: one new shared constant, one new invoice-data field, one schema-version bump. No changes
to existing endpoints, no default changes, no dependency changes.
Added
OrderPayable::TYPEconstant (Glueful\Extensions\Commerce\Payments\OrderPayable) — the one
Commerce payable-type string ('commerce_order'), now consumed by every production call site that
previously re-declared the literal privately (CheckoutService::initiatePayment(),
OrderPaymentConfirmationHandler::supports(),OrderNotifiable::getNotifiableType(),
RefundService::callAndFinalize(),ChargebackService::SUPPORTED_PAYABLE_TYPE).InvoiceDatagainsorder.currency_exponent— derived from the order's owncurrency
through the existingMoney::exponentFor()authority (never a second ISO map, never tenant
settings). An order whose stored currency isn't a recognized ISO 4217 code now throws rather than
silently defaulting the exponent to 2.schema_versionbumps1 → 2to reflect the new field.
v1.9.0 - Host-Bound Payment Return URLs & Payable Metadata
[1.9.0] - 2026-08-01 — Host-Bound Payment Return URLs & Payable Metadata
Additive throughout: one new optional contract, two optional trailing constructor collaborators,
and richer payable metadata. No changes to existing endpoints, no default changes, no dependency
changes — an install that binds nothing new behaves exactly as 1.8.0.
Added
- Payment payable metadata + host-bound return URLs —
CheckoutService::initiatePayment()
now feeds payvia's payable metadata convention: the payeremailon every payable (Paystack
requires it at initialize), andcallback_url/cancel_urlfrom a new optional
Glueful\Extensions\Commerce\Contracts\OrderPaymentReturnUrlProvidera HOST app may bind
(commerce is headless and cannot know the storefront's return pages). The provider receives the
COMPLETED order row (order number final), so one path serves initial placement, durable replay,
andretryPayment()— no placeholder substitution, no request-host trust. Returned URLs are
validated as absolute HTTPS; a throwing provider or invalid output is logged and degrades the
payment leg to the existinginit_failedresult without rolling back the placed order. Absent
binding = no URL metadata, byte-identical to today.CheckoutServicealso gains an optional
trailingLoggerInterfacecollaborator (NullLogger default) — every existing construction site
is unaffected.
v1.8.0
[1.8.0] - 2026-07-29 — Storefront Card Reads & Account Seams
Additive throughout: two new tables, four new storefront routes inside the existing auth +
tenant account group, one new service seam, and four batched catalog reads. No changes to
existing endpoints, no default changes, no dependency changes. Installs that use none of the new
surfaces behave exactly as before.
Added
- Four additive batched catalog reads for storefront card surfaces (products-by-uuids with
buyer availability, first-category projections, required-add-on presence, primary media) —
ProductRepository::findActiveBuyerAvailableByUuids()(reusesactiveFilteredQuery(), the
buyer-availability predicate authority),CategoryRepository::firstCategoryProjectionsForProducts(),
AddonRepository::hasRequiredForProducts(), andProductMediaRepository::primaryForProducts().
All four share one pinned uuid-list normalizer (Support\UuidBatch: malformed dropped,
first-occurrence dedupe, first-100 cap, empty → no query) and issue exactly one query per
non-empty call. No schema changes, no new routes, no writes. - Account-backed wishlist:
commerce_wishlists+commerce_wishlist_items(migration 020),
WishlistService, andGET/POST/DELETE /commerce/account/wishlistwith
POST /commerce/account/wishlist/import. A parent list row per (tenant, user) carries the
revision claim every growth path takes, so the 100-item cap holds under concurrent saves and
imports instead of being a count-then-insert race (proved by three real-PostgreSQL races).
Display order is an explicitposition— saves take the front, imports append to the back —
not a timestamp, which would put freshly imported rows ahead of the account's own older items.
Inserts run in a nested transaction so a unique violation cannot poison the caller's
transaction, and only a verified duplicate becomes an idempotent no-op. Availability is
checked before a product can consume a slot and re-checked on every read, so an inactive
product leaves the list without deleting the saved row. The cap is refused explicitly rather
than evicting a deliberately saved item, and import reports imported/unavailable/overflow
separately — including valid identifiers beyond the batch limit, which would otherwise be
dropped silently. Both tables join the tenancy inventory, so registration, adoption,
diagnostics and purge carry them. GuestOrderClaimService: customer-safe claiming over the existing race-safe
OrderRepository::linkGuestToUser().claim()requires BOTH the guest credential and a
normalized verified-email match; every failure returns the same non-revealing 404, and
re-claiming an order you already own is a no-op success.claimAllByVerifiedEmail()is a
deliberately weaker, bounded historical import for hosts to expose as an explicit, confirmed,
audited action. Shipped as a service seam with no route: Commerce cannot establish that an
email is verified, so the calling application — which owns the verified-account context —
invokes it server-side.UuidBatch::UUID_PATTERNis now public: the single anchored catalog-identifier pattern shared
by batch normalization, the wishlist service and the HTTP DTOs.
v1.7.0 - Runtime Download-Link Lifetime & Marketplace Master Switch
[1.7.0] - 2026-07-25 — Runtime Download-Link Lifetime & Marketplace Master Switch
Two additive keys on the 1.6.0 settings seam. No schema changes, no new env vars, no dependency
changes; installs without a seam binding behave exactly as before.
Added
commerce.marketplace.enabledjoins the runtime-settings seam —
CommerceSettings::marketplaceEnabled()(boolean-ish override, config/env fallbackfalse),
now backingMarketplaceMode::installEnabled()— the single choke point both behavioral
consumers (CheckoutService's master-off fast path, the seller webhook outbox publisher)
re-check per call. A host settings screen can therefore switch marketplace mode on and off at
runtime. Commerce's OWN marketplace REST routes remain gated on the raw env value in
routes.php — route registration is genuinely boot-time and precedes tenant context.
Added
commerce.downloads.url_ttljoins the runtime-settings seam —CommerceSettings::downloadsUrlTtl()
(override-first, defensive int casting, config/env fallback300), consulted by both signed
download URL producers (DownloadUrlSignerandCommerceDownloadBlobPolicy's grant window),
so a host settings screen can tune download-link lifetime at runtime without touching env.
v1.6.0 - Per-Product Order Activity, Catalog List Summary & Settings Seam
[1.6.0] - 2026-07-25 — Per-Product Order Activity, Catalog List Summary & Settings Seam
One additive admin read, an additive enrichment of the admin product list, and a host seam that
makes store settings runtime-editable (Thallo's Store settings tab is built on it). No schema
changes, no new env vars, no dependency changes; every addition is inert unless a host binds it.
Added
-
GET /admin/products/{uuid}/orders(products.orders.index, catalog 104 → 105): the
most recent orders CONTAINING a product plus a windowed, product-attributed activity summary
—{window_days, summary: {orders, revenue_minor}, recent: [...]}. Orders join through their
lines' variants, so a multi-line order appears once;recentrows cross the wire through the
sameOrderProjection::forAdminwhitelist as every admin order surface; the summary mirrors
the products report's discipline exactly (statusespaid/fulfilled/refunded, report time
placed_atfalling back tocreated_at, revenue = the SUM of THIS product's line totals,
never orders' grand totals).daysclamps to 1-365 (default 30),per_pageto 1-20
(default 5); unknown/cross-tenant/tombstoned products 404 non-revealing. -
Price/stock summary on
GET /admin/products— six additive keys per row
(variant_count,price_from,price_to,currency,stock_quantity,stock_tracked)
so an admin catalog list can show what a product costs and how many are on hand. Two batched
reads cover the whole page (VariantRepository::forProductsplus the new
StockRepository::stockProjectionsForProducts), never one pair per row.price_fromequals
price_tofor single-variant products, and price spans ALL variants — the merchant's own
catalog view, with the row'sstatusanswering sellability.stock_quantityis never a
fabricated zero: it isnullwhen nothing is tracked AND when any variant has lost its
commerce_stockrow (an unknown total beats a silently-short sum). Unlike the per-product
products.stock.indexread, that drift does NOT throw here — a browsing list must not 500
because one variant drifted, andDiagnosticsReport'svariants_missing_stockremains the
loud channel. No new route: the existingproducts.indexentry simply returns more per row. -
Runtime-editable store settings seam —
Support\CommerceSettingsOverride(a host
contract shaped likeCommerceTenantResolution: apps MAY bind an implementation, e.g. one
backed by a tenant-scoped settings table) plusSupport\CommerceSettings, the one typed read
surface forcommerce.currency,commerce.tax.flat_rate_bps,
commerce.orders.number_format,commerce.orders.expiry_minutes,commerce.cart.ttl_days,
commerce.reports.low_stock_threshold, and the null-tolerant invoice identity keys
(commerce.seller.name/address/tax_id, read byConfigSellerIdentityProvider). Every
internal read of those keys now goes
through it: a bound override wins (consulted at USE time, mid-request — the only ordering
compatible with tenant-scoped stores, sinceoverrideConfig()is boot-only by design), a
malformed or null override falls back to config, and with no binding behavior is byte-for-byte
what it was. Implementations must return null rather than throw — config/env stays the
always-working fallback. -
Existence probes + setup-time currency support —
OrderRepository::anyExistsForTenant
(the currency LOCK's predicate for hosts: recorded orders are the durable money history),
VariantRepository::anyExistsForTenant(a "priced products exist" hint, e.g. to warn that a
setup-time currency change reinterprets draft prices), and
VariantRepository::reassignCurrencyForTenant(rewrites every variant's currency CODE while
keeping amounts exactly as typed — required because checkout hard-rejects variants whose
currency doesn't match the store, so an unlocked currency change must carry the catalog with
it).
Upgrade Notes
- Hosts using restricted mounts must allowlist
products.orders.indexto adopt it (fail-closed
as always — nothing mounts until approved).