Skip to content

Port the v1.5.0 fixes into v2 (spec 0065) - #2655

Merged
glennjacobs merged 11 commits into
2.xfrom
spec/0065-port-v1.5-fixes
Aug 27, 2026
Merged

Port the v1.5.0 fixes into v2 (spec 0065)#2655
glennjacobs merged 11 commits into
2.xfrom
spec/0065-port-v1.5-fixes

Conversation

@glennjacobs

Copy link
Copy Markdown
Contributor

Ports the outstanding v1.5.0 fixes to 2.x (spec 0065).

v1.5.0 shipped 36 changes on the 1.x line (PRs #2409#2648). 2.x forked at #2382, so none arrived by cherry-pick, and none could be — namespaces moved to Lunar\Core\…, Base/ was reorganised, actions are constructor-injected with typed signatures, money is PriceValue, admin split into admin/filament, and the Inertia panel is a second UI surface. Each fix was re-implemented in its v2 location.

Every 1.5.0 commit was compared against the 2.x tree at code level first:

What's here

One commit per slice, in dependency order.

Area Ports
BuyXGetY Conditions guard (#2624); variant rewards (#2631); morph-map reward lines (#2632); multi-quantity as one line (#2639); fulfillable-only rewards (#2509)
Discounts Cart-scoped memoisation key (#2623); a cart's own consumption not counting against it (#2637, #2638)
Cart/tax/shipping Currency repricing (#2625); state tax zones scoped to country (#2626); modifier re-entrancy guard (#2630); TaxZone::getDefault() (#2511); nullable getDefault() (#2634); weight tiers in the configured unit (#2645)
Core models Deterministic relation ordering for PostgreSQL (#2518); one order address per type (#2507)
Filament admin Input trimming (#2642); variant option mapping + tax class (#2640); media custom properties (#2598); country/state select labels (#2612); boolean meta as Yes/No (#2545); unit-quantity minimum (#2547); widget label i18n (#2555); collection breadcrumb gaps (#2557); digital lines on the PDF invoice (#2409)
Payments/pricing Stripe amount conversion by decimal places (#2601) + the test pin it makes necessary (#2644); shipping permission labels (#2484); formatterStyle typed int (#2520)

Where v2 needed more than the 1.x fix

Several of these are worse in v2, or land differently:

  • preventLazyLoading (spec 0011) is enforced in v2 and not in 1.x. The BuyXGetY fulfillable-reward filter reads variants off a Product arriving through the discountable morph, which threw; those reads now go through the relation query. The same class of problem surfaced in AppliesToExistingSelect, which was N+1'ing to build labels — selectors now declare what their label reads via optionLabelRelations().
  • Real inventory raises the stakes on Ensure automatic discounts can be fulfilled #2509. CartLineStock rejects an unfulfillable line, so an out-of-stock automatic reward wedged the cart rather than just being a userland oddity.
  • Morph-alias matching makes Store automatically added BuyXGetY reward lines using the morph map #2632 self-perpetuating. apply() matches lines on aliases, so an FQCN-typed reward line was never re-matched and the discount kept adding more.
  • Spec 0064 does not cover Scope the memoised discount set to the cart it was built for #2623. Scoping fixed leakage across requests; this is intra-request staleness (coupon accepted, total unchanged until reload), which was fully present.
  • The digital-lines invoice bug survived the v2 refactor in a new form. The template iterates fulfillableLines, and a non-shippable variant has requires_fulfilment = false, so digital lines still vanished. Other fulfillableLines call sites were audited and are genuine fulfilment concerns.
  • Convert Stripe amounts based on currency decimal places (#2516) #2601 supersedes Convert Stripe amounts for zero-decimal currencies #2467, already on 2.x. That one early-returned for everything but HUF/TWD/UGX, so a merchant whose decimal_places disagreed with Stripe's scale was charged 100x, and three-decimal currencies were never handled.
  • The test(stripe): pin StoreChargesTest currency to stop a random flake #2644 flake is real and measured. Once StoreCharges converts, CartBuilder's random faker currency diverges from the fixture on ~14% of rolls (2000-roll sample) — an intermittent red, so both tests in the file are pinned.
  • fix(pricing): correct formatterStyle parameter type from string to int #2520 needs one more file than 1.x did. v2's pricing refactor widened PriceFormatterInterface to declare the parameter list, so contract and implementation must change together — which makes it a signature change on published surface, handled by a new RetypeFormatterStyleParamRector.

Decisions worth a look

  • Storefront currency switches now reach the cart. StorefrontSession::setCurrency() never touched it, so a region switch left the cart priced in the old currency — a gap 1.x didn't have. It delegates to CartSession, while the boot cascade writes through a non-propagating putCurrency() so resolving a currency per request neither calculates nor creates a cart.
  • ShippingManifest moves from singleton to scoped and joins ServiceLifetimesTest. It holds the options resolved for the cart in hand plus the new re-entrancy flag — per-request state, not a boot-time registry.
  • Shipping permission labels go in core, not a runtime merge. The 1.x mergeTranslationsForPanel() hack existed because v1's key lived where the addon couldn't write. v2's flat Permission DTO key is read by both surfaces, so the keys simply go in core across 16 locales. A third-party addon uses Lang::addLines() into the lunar namespace — the native API, nothing to build. The v1-era lunarpanel.shipping translation namespace is left alone: renaming it breaks every call site and any published override, so it wants its own spec and Rector rule.
  • Stock events (Dispatch event on product/variant inventory update (#2226) #2606/Missing event dispatch on product/variant inventory update in Filament resource #2226) are deferred. The 1.x shape — an admin-namespaced event dispatched from a Filament page — is the wrong layer for v2 and would miss the panel controller, AdjustStock, and every automatic movement. What v2 needs is spec 0038's unshipped events section, which is new surface rather than a port; it's tracked in TODO.md.
  • One file removed: packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php. The widget's $view points at the bridge namespace and nothing references the admin copy, so it was unreachable — but flagging it, since it's a removal from a published package.

Migration impact

  • No database changes — every schema-adjacent 1.x change is already in the v2 baseline.
  • Public surface is additive, except PriceFormatterInterface::$formatterStyle (stringint), covered by the new Rector rule.
  • Translations: four batches — :unit in table-rate-shipping relationmanagers.php (14 locales), yes/no in admin global.php (16), save-variants.label in filament productoption.php (16), shipping:manage label/description in core auth.php (16). Translated per locale, no English placeholders.
  • Panel: untouched. Where a fix belongs to a panel section not yet built (orders, discounts), it's noted in the spec as an acceptance criterion for that section.

Tests

Every ported fix has a test that was confirmed to fail without it. All CI suites pass, plus Pint and PHPStan:

core admin panel filament shipping stripe search upgrade cross-db
1253 261 745 73 103 48 28 85 105

Still open

Recorded in the spec, not blocking:

  • After annotating getDefault() nullable, the typed CartSessionManager::getCurrency()/getChannel() still fail with a TypeError when no default record exists. Whether that should be a domain exception is its own question.
  • Whether attribute default_value should come back to v2 — it would follow the spec-0062 validation-rules template, as its own spec.

🤖 Generated with Claude Code

glennjacobs and others added 11 commits August 26, 2026 17:52
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports five 1.5.0 discount fixes:

- Guard apply() on checkDiscountConditions(), so min spend, customer
  restrictions and max_uses_per_user apply as they do for AmountOff (#2624).
- Handle a variant reward directly instead of reaching for ->variants,
  which fatalled and took down cart calculation (#2631).
- Write reward lines with getMorphClass(), so they match every other line
  and stay matchable by the discount on the next calculate (#2632).
- Add a multi-quantity reward as one line of N rather than N lines of one,
  tracking the lines this run created (#2639).
- Skip rewards that cannot be fulfilled, and cap allocation against stock
  (#2509). More consequential in v2 than 1.x: CartLineStock rejects an
  unfulfillable line, so an out-of-stock automatic reward wedged the cart.

Product rewards arrive through the discountable morph, so `variants` is
never eager loaded — those reads go through the relation query to stay
inside preventLazyLoading().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two 1.5.0 discount fixes:

- Key the memoised discount set on the cart state it was built from
  (id, coupon, customer, line purchasables) rather than reusing any
  non-empty set (#2623). getDiscounts() filters on the coupon, so a set
  built before the shopper entered one could never contain their coupon
  discount — the total stayed unchanged until the next request. Spec 0064
  scoped the manager's lifetime, which fixes leakage across requests but
  not this staleness within one.

- Stop a cart's own discount consumption counting against it (#2637,
  #2638). Order creation records a use as soon as the draft order exists,
  so a retry after a declined card found its own single-use coupon
  exhausted and re-priced the order without it. Cart::consumedDiscountIds()
  reads the draft order's breakdown (memoised per instance, forgotten by
  CreateOrder once it rewrites the breakdown), Discount::scopeUsable()
  takes an exempt list, and the use limits in checkDiscountConditions()
  and the markAsUsed() loop both honour it. Coupon match, min spend,
  customer restrictions, dates and channel are still enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntrancy

Ports five 1.5.0 fixes and scopes the shipping manifest:

- Reprice the cart when the session currency changes (#2625). setCurrency()
  wrote currency_id but left the loaded currency and lines relations in
  place, and lines carry their own copy of the cart, so the next calculate
  priced in the old currency. StorefrontSession::setCurrency() — a second
  switching path in v2 that never touched the cart — now delegates to
  CartSession, while the boot cascade writes through a non-propagating
  putCurrency() so resolving a currency per request neither calculates nor
  creates a cart.

- Scope state tax-zone lookups to the address country (#2626). A state
  name or code is only unique within a country, so Washington's zone
  hijacked Australian addresses; states with no country still match any.

- Guard the shipping-modifier pipeline against re-entry (#2630). A
  modifier that calculates the cart landed back in getOptions() until the
  stack was exhausted. The manifest also moves to scoped and joins
  ServiceLifetimesTest: it holds the options resolved for the cart in hand
  plus the new flag, so it is per-request state, not a boot-time registry.

- Route the default tax zone through TaxZone::getDefault() (#2511), which
  is Blink-cached — getBreakdown() reaches for it per purchasable.

- Annotate getDefault() as null|self (#2634); it returns first().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t_unit

Ports #2645. ShipBy summed cart weight in kg regardless of the method's
weight_unit, so a method configured in grams compared a kg total against
gram tiers and every cart landed in the lowest tier. The constraint path
in ShippingRateResolver was already unit-aware; only the tier path was
not — this is distinct from #2382, which changed the tier comparison
scale rather than its unit.

The admin form followed the same assumption: it showed a hardcoded kg
suffix and "kilograms" helper text, and silently truncated decimals.
Both now read the configured unit, and an integer rule rejects decimals
rather than truncating, since tiers are stored as raw integers. The
helper text gains a :unit placeholder across all 14 locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…type

Ports two 1.5.0 fixes:

- Give Cart::lines(), Order::lines() and ProductVariant::values() an
  explicit ORDER BY (#2518). Row order without one is undefined by the
  SQL standard: InnoDB returns clustered primary-key order by
  coincidence, PostgreSQL returns heap order, which shifts after an
  UPDATE. Lunar depends on a stable sequence — GenerateFingerprint
  reduces $cart->lines in iteration order, and getOption() is snapshotted
  onto order lines — so it has to be deterministic across engines.
  Derived relations chain off lines() and inherit the fix.

- Match an existing order address on type alone (#2507). Matching on type
  and postcode meant a shopper changing their postcode before a recalculate
  got a second address row of the same type; the type column is indexed,
  not unique, so nothing caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…one selects

Ports four 1.5.0 admin fixes:

- Trim TextInput/Textarea/TagsInput at the form layer (#2642). Livewire
  skips the TrimStrings middleware, so admin input persisted with leading
  and trailing whitespace. The Inertia panel needs nothing — its requests
  go through the normal HTTP stack.

- Fix product option mapping and the new-variant tax class (#2640).
  collect()->search() returns false on no match and PHP coerces
  $variants[false] to index 0, so sparse matrices showed the first
  variant's SKU under the wrong option labels. A newly filled permutation
  also had nothing to copy tax_class_id or a base price from, so the
  insert violated NOT NULL; it now copies the oldest sibling, with a
  widget-side fallback.

- Keep media custom properties the form did not render (#2598). Create
  hand-picked name and primary, discarding anything an extension added,
  and edit wrote the array-cast column wholesale, clobbering unrendered
  keys. Create now passes the submitted set through and edit merges over
  what is stored.

- Resolve country/state select labels from the submitted values rather
  than the persisted relationship (#2612). A fresh selection had no label
  yet, so Filament rejected the field as invalid. Fixes the same defect in
  ShippingZoneForm's single-country select, which plucked country.name off
  a relation that points straight at Country and so was always blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oice lines

Ports five 1.5.0 admin fixes:

- Render boolean order meta as localized Yes/No (#2545) instead of PHP's
  "" and "1" cast. Adds the two keys across all 16 admin locales.

- Enforce a unit quantity of at least one (#2547) on the variant form and
  clamp it in DefaultPriceFormatter, whose unitDecimal() divides by it.

- Label the product-options widget actions and empty-state heading from
  translations (#2555). Filament humanised the action names, so "Save
  Variants" was hardcoded English in every locale; the new key is
  translated across all 16 filament locales. The stale duplicate blade
  under packages/admin is removed rather than fixed — the widget's $view
  points at the bridge namespace, so nothing could reach it.

- Include non-shippable lines on the PDF invoice (#2409). The bug survived
  the v2 refactor in a new form: the template iterated fulfillableLines,
  and a non-shippable variant has requires_fulfilment false, so digital
  lines still vanished from the invoice body. The remaining
  fulfillableLines call sites are genuine fulfilment concerns.

- Close the collection breadcrumb gaps left by the CollectionSelect
  refactor (#2557): the group name now prefixes the trail, so two
  same-named collections in different groups are distinguishable, and the
  condition relation manager gains the breadcrumb description its
  limitation counterpart already had. Group and ancestors are eager loaded
  everywhere the label is resolved, including the AttachAction path, which
  previously N+1'd and would trip preventLazyLoading().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports #2484. The permission is registered by the shipping package's own
migration, but nothing labelled it, so both panels rendered the raw
handle "shipping:manage" in the staff and roles UI.

The 1.x fix merged the addon's translations into the panel namespace at
boot because v1's label key lived somewhere the addon could not write.
v2 centralises resolution in the Permission DTO on a flat
lunar::auth.permissions.{handle}.* key that both surfaces read, so the
keys simply go in core across all 16 locales — one fix, no runtime
merging. A third-party addon labels its own permission with
Lang::addLines() into the lunar namespace.

The v1-era `lunarpanel.shipping` translation namespace is left alone:
renaming it would break every call site and any published overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports #2601, which supersedes the zero-decimal fix already on 2.x
(#2467). That one early-returned for everything but HUF/TWD/UGX, so a
merchant whose Currency::decimal_places disagreed with Stripe's scale for
that currency was charged by a factor of 100 — and it never handled
three-decimal currencies at all.

toStripeAmount() now de-scales to the major unit using decimal_places and
re-scales to whatever sub-unit Stripe expects, using integer arithmetic
throughout: float division misrounds half-unit boundaries (145 at 3dp is
0.145, which stores as 0.1449… and rounds down). fromStripeAmount() is
the inverse, applied wherever an amount comes back from Stripe. Capture,
refund, the intent-total assertion and StoreCharges all convert.

Bundled with #2644: once StoreCharges converts, CartBuilder's random
faker currency code makes the stored amount diverge from the raw fixture
amount whenever it rolls a currency Stripe scales differently — measured
at ~14% of rolls — so the currency is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports #2520. NumberFormatter::CURRENCY and its siblings are int
constants, so passing one tripped a coercion deprecation. v2 needs one
more file than 1.x did: the pricing refactor widened
PriceFormatterInterface to declare the full parameter list, so the
implementation and the contract have to change together.

That makes it a signature change on a published interface, so
RetypeFormatterStyleParamRector retypes a consumer's own formatter
methods on upgrade.

Marks spec 0065 implemented and moves it to specs/completed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-project-automation github-project-automation Bot moved this to Todo in Roadmap Aug 26, 2026
@glennjacobs
glennjacobs marked this pull request as draft August 27, 2026 14:02
@glennjacobs
glennjacobs marked this pull request as ready for review August 27, 2026 14:02
@github-actions github-actions Bot added the high label Aug 27, 2026
@glennjacobs
glennjacobs merged commit 0963ce0 into 2.x Aug 27, 2026
24 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Aug 27, 2026
@glennjacobs
glennjacobs deleted the spec/0065-port-v1.5-fixes branch August 27, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant