Skip to content

Releases: glueful/commerce

v1.14.0

Choose a tag to compare

@MichaelSowah MichaelSowah released this 08 Oct 00:27
3daee51

[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

  • ResolvedProductFilters takes lists (breaking): categoryUuids and tagUuids are any-of
    lists, ANDed with each other; the single categoryUuid / tagUuid arguments 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: true keeps products with an active
    variant that is untracked, has no stock row, or is tracked above zero.
  • Product list order: listActive() takes a ProductSort — newest (default), price_asc,
    price_desc (by the lowest active variant price; unpriced products last) and name.
  • Batch taxonomy projections: CategoryRepository::categoryProjectionsForProducts() and
    TagRepository::tagProjectionsForProducts() — every category or tag of a list of products in
    one query.

v1.13.0

Choose a tag to compare

@MichaelSowah MichaelSowah released this 18 Aug 01:34
342d7db

[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

Choose a tag to compare

@MichaelSowah MichaelSowah released this 14 Aug 09:21
6d9413e

[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, manage mode, the 119th admin route). A draft artifact is an
    order row that was canceled while it was still a draft: order_number IS NULL and
    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 its commerce_order_lines, commerce_order_events and
    commerce_order_draft_attempts in ONE transaction. Refusals are a closed set: a typed 409
    order_not_deletable
    (carrying the observed status) 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() and commerce.orders.draft_purge_days (default 30,
    clamped 1–365) — the aged sweep behind the SAME guarded delete. Canceled numberless artifacts
    whose COALESCE(updated_at, created_at) is older than the window are destroyed in batches, and
    it is wired as a THIRD independent sweep on the existing commerce:orders:expire tick, 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 the DELETE's own WHERE clause 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_count on 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 — and DraftOrderProjection publishes it as a
    derived wire field on the listing, the detail, and every mutation response alike. lines itself
    stays unhydrated on the listing by design; DraftOrderProjection::FIELDS is unchanged (it is the
    commerce_orders column whitelist, and line_count is not a column).
  • OrderNotFoundException — OrderRepository::transition() reported a vanished (unknown or
    cross-tenant) order as a bare \RuntimeException separable 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 generic Order not found. message, so nothing that caught one or read one breaks).
  • ConcurrentOrderTransitionException — the LOSER of transition()'s compare-and-set is now
    separable from an outright illegal transition. Still a \DomainException carrying the identical
    message, so every catch (\DomainException) -> 409 caller is unaffected; it additionally carries
    from/to and the observed status read back immediately after the failed CAS.

Changed

  • Draft-artifact deletion is the one deliberately UNAUDITED destructive operation in this
    engine.
    It records no commerce_order_events row — one would reference an order that no
    longer exists, and eventsForOrder() joins through commerce_orders, so it would be
    unreadable the moment it was written. In its place a commerce.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:expire now 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 (true iff THIS call performed the pending_payment -> paid transition). 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 showing paid
    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.
  • OrderPaymentConfirmationHandler routes a conceded settlement to rejectLatePayment() —
    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-blind order() 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:expire starts deleting canceled draft artifacts after this upgrade. The
    cron's new third sweep (DraftCleanupService::purgeStale()) permanently removes any order row
    matching order_number IS NULL AND status = 'canceled' whose COALESCE(updated_at, created_at)
    is older than commerce.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 raise COMMERCE_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 new DELETE /orders/{uuid}/artifact route, and any
    UI action wired to it, will 404 at the host router until the key is added.
  • OrderPaymentService::markPaid() signature widened void → bool. The method is final
    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 the pending_payment → paid
    transition (false means 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...
Read more

v1.11.0 - Payment Links & Session-Exposure Guarded Cancellation

Choose a tag to compare

@MichaelSowah MichaelSowah released this 12 Aug 02:34
afe385a

[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_links table (migration 023) — hashed token custody: every row stores a
    SHA-256 token_hash, never a raw token; the raw value is engine-egress-limited to mint()'s
    return and mintPublic()'s composed URL, pinned by a reflection ratchet over every public
    signature in the custody path. status and token_hash are 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 stamping provider_session_issued_at.
    Calling initiateByToken() from inside a caller-owned transaction throws a dedicated
    LogicException rather 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 as payment_session_risk_accepted in the same
    transaction as the cancellation). The exposure sweep-exclusion prefilter is a lease-aware
    addition to ExpiryService; 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). store mints via mintPublic() only, never the
    raw-token-returning mint(); show reads via a new token-free PaymentLinkService::currentLink()
    and reports the current exposure decision; destroy revokes 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 to canceled or release its stock, so a future
    authority cannot be added without also wiring the guard.
  • CheckoutService::paymentReturnMetadata() now shares the same Support\HttpsUrl::isAbsoluteHttps()
    definition the payment-link return-URL seam uses, removing a duplicate inline validation
    predicate; behavior is unchanged except that an uppercase HTTPS:// scheme, previously
    rejected, is now correctly accepted per RFC 3986.

Host obligations (Thallo and any embedding host)

  • Bind Contracts\PaymentLinkPublicUrlProvider and Contracts\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=On in 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 typed PaymentLinkException to 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 the PaymentCollector contract consumed here is unchanged from
    2.5, so no composer.json constraint moves in this release — every collector failure mode,
    including a 2.6 renewal refusal, is caught and surfaced as the typed checkout_initiation_failed
    refusal rather than an uncaught exception.

v1.10.0 - Admin Draft Orders & Walk-In Finalization

Choose a tag to compare

@MichaelSowah MichaelSowah released this 10 Aug 02:40
404ef7d

[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

  • draft order 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 CAS finalizeDraftTransition(), 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-increments draft_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 — OrderPlaced dispatches only
    after commit, carrying the new origin field.
  • Walk-in order schema — nullable order_number/email/guest_token_hash; new
    phone_normalized (strict E.164) + phone_display, customer_name, origin
    (storefront|admin, backfilled storefront), fulfillment_mode (in_store|delivery,
    backfilled delivery as conservative compatibility), draft_revision; new
    commerce_order_draft_attempts ledger (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-line sellerUuid exposure.
  • OrderFulfillmentService — non-partitioned fulfillment extracted from the admin controller
    (owns the transaction and exactly-once OrderFulfilled dispatch) 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_confirmation toggle 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/mine and the checkout placement response now pass
    through a closed StorefrontOrderProjection allowlist (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

Choose a tag to compare

@MichaelSowah MichaelSowah released this 09 Aug 04:14
3fa9f1e

[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::TYPE constant (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).
  • InvoiceData gains order.currency_exponent — derived from the order's own currency
    through the existing Money::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_version bumps 1 → 2 to reflect the new field.

v1.9.0 - Host-Bound Payment Return URLs & Payable Metadata

Choose a tag to compare

@MichaelSowah MichaelSowah released this 01 Aug 01:09
8d65736

[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 payer email on every payable (Paystack
    requires it at initialize), and callback_url/cancel_url from a new optional
    Glueful\Extensions\Commerce\Contracts\OrderPaymentReturnUrlProvider a 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,
    and retryPayment() — 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 existing init_failed result without rolling back the placed order. Absent
    binding = no URL metadata, byte-identical to today. CheckoutService also gains an optional
    trailing LoggerInterface collaborator (NullLogger default) — every existing construction site
    is unaffected.

v1.8.0

Choose a tag to compare

@MichaelSowah MichaelSowah released this 29 Jul 19:22
5a14acf

[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() (reuses activeFilteredQuery(), the
    buyer-availability predicate authority), CategoryRepository::firstCategoryProjectionsForProducts(),
    AddonRepository::hasRequiredForProducts(), and ProductMediaRepository::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, and GET/POST/DELETE /commerce/account/wishlist with
    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 explicit position — 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_PATTERN is 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

Choose a tag to compare

@MichaelSowah MichaelSowah released this 25 Jul 22:39
ec72304

[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.enabled joins the runtime-settings seam —
    CommerceSettings::marketplaceEnabled() (boolean-ish override, config/env fallback false),
    now backing MarketplaceMode::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_ttl joins the runtime-settings seam — CommerceSettings::downloadsUrlTtl()
    (override-first, defensive int casting, config/env fallback 300), consulted by both signed
    download URL producers (DownloadUrlSigner and CommerceDownloadBlobPolicy'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

Choose a tag to compare

@MichaelSowah MichaelSowah released this 25 Jul 20:06
9fa1d97

[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; recent rows cross the wire through the
    same OrderProjection::forAdmin whitelist as every admin order surface; the summary mirrors
    the products report's discipline exactly (statuses paid/fulfilled/refunded, report time
    placed_at falling back to created_at, revenue = the SUM of THIS product's line totals,
    never orders' grand totals). days clamps to 1-365 (default 30), per_page to 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::forProducts plus the new
    StockRepository::stockProjectionsForProducts), never one pair per row. price_from equals
    price_to for single-variant products, and price spans ALL variants — the merchant's own
    catalog view, with the row's status answering sellability. stock_quantity is never a
    fabricated zero: it is null when nothing is tracked AND when any variant has lost its
    commerce_stock row (an unknown total beats a silently-short sum). Unlike the per-product
    products.stock.index read, that drift does NOT throw here — a browsing list must not 500
    because one variant drifted, and DiagnosticsReport's variants_missing_stock remains the
    loud channel. No new route: the existing products.index entry simply returns more per row.

  • Runtime-editable store settings seam — Support\CommerceSettingsOverride (a host
    contract shaped like CommerceTenantResolution: apps MAY bind an implementation, e.g. one
    backed by a tenant-scoped settings table) plus Support\CommerceSettings, the one typed read
    surface for commerce.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 by ConfigSellerIdentityProvider). 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, since overrideConfig() 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.index to adopt it (fail-closed
    as always — nothing mounts until approved).