Skip to content

chore(release): v0.4.19 - #88

Open
roncodes wants to merge 27 commits into
mainfrom
dev-v0.4.19
Open

chore(release): v0.4.19#88
roncodes wants to merge 27 commits into
mainfrom
dev-v0.4.19

Conversation

@roncodes

@roncodes roncodes commented Aug 10, 2026

Copy link
Copy Markdown
Member

Release scope

This is the Storefront v0.4.19 release branch. It includes the patch version bump, contract-workflow updates, API reliability fixes already accumulated on dev-v0.4.19, and the restored marketplace backend required by the refactored Storefront App.

Marketplace API

  • return paginated network member stores with search, category/tag, online, rating, trending, age, popularity, nearest, and maximum-distance filters
  • expose marketplace member locations, categories, products, merchant metadata, reviews, and payment gateways while preserving store-key location and tag contracts
  • tenant-scope carts and validate every line's merchant and store location
  • enrich cart lines with merchant data without per-line queries
  • enforce online membership, product publication/availability, store-location ownership, multi-store policy, and one currency before checkout
  • treat authenticated Customer-Token identity as authoritative and reject mismatched customer IDs with 403
  • resolve multi-origin service quotes in bulk and preserve one delivery origin per merchant
  • constrain store- and product-subject review lookup, creation, and deletion to the authenticated storefront context and owning customer

Security and compatibility

The implementation fails closed across network membership and tenant boundaries. Foreign store, category, product, review, and location IDs are not exposed. Existing single-store API behavior remains supported while network keys gain explicit marketplace behavior. Store keys retain access to their own newly created products for retrieval and cart workflows; marketplace keys only expose published, available products from current member stores.

Validation

  • full backend suite: 424 tests, 2675 assertions
  • Clover line coverage: 100.00% (7127/7127)
  • Clover method coverage: 100.00% (675/675)
  • Clover class coverage: 100.00% (134/134)
  • PHP CS Fixer dry run across the full changed PHP set: clean
  • Postman Native Git collection lint: 288 requests, 0 errors, 0 warnings
  • live Postman regression comparison: all 16 marketplace-induced assertions restored to passing; 38 pre-existing fixture/integration assertions remain unchanged from the preceding branch run
  • git diff --check: clean

The repository PHPStan configuration currently points at a missing src directory. An explicit changed-path run reaches the existing framework/model-magic baseline rather than producing a usable package gate; this PR does not weaken static-analysis configuration.

Related PRs and rollout

  1. Backend API: this PR
  2. SDK: feat: add marketplace SDK helpers storefront-js#11
  3. App: feat: restore marketplace edition storefront-app#54
  4. API specification: docs: document marketplace API contracts postman#34
  5. Documentation: docs: add marketplace edition guide fleetbase.io#90

Accept the API and SDK contracts before releasing the app. Documentation and API examples should land with the corresponding backend release.

Review focus

Human review is requested for public API compatibility, network isolation, authenticated customer identity, multi-merchant payment behavior, delivery quote origins, review-subject scoping, and release sequencing.

roncodes and others added 4 commits August 8, 2026 00:00
The contract job pinned the reusable workflow to @dev-v0.7.53, a pre-release
branch. That branch is now merged (fleetbase/fleetbase#575) and v0.7.53 is
tagged, with fleetbase/fleetbase-api:v0.7.53 published to Docker Hub.

  - pins the reusable workflow to @v0.7.53 instead of the dev branch, so runs
    are reproducible rather than tracking a branch that can move or be deleted
  - passes fleetbase-ref: v0.7.53 explicitly. The reusable workflow still
    defaults that input to dev-v0.7.53, so without this the job would boot the
    stack from the pre-release branch while testing against the released image.
    Passing it makes the booted source and the published image the same commit.

Bump both refs together at each release.

Contract runs on this repo were previously failing before they reached Postman —
the installer step died building the console image, because console/package.json
and console/pnpm-lock.yaml were briefly out of sync on the release branch and
console/Dockerfile installs with --frozen-lockfile. That is fixed in v0.7.53.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fleetbase/fleetbase#578 changed the reusable workflow to default fleetbase-ref
to main and to test against fleetbase/fleetbase-api:latest, so there is no
longer a per-release ref to bump here. Drops the explicit fleetbase-ref and
moves the workflow reference from @v0.7.53 to @main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With build-from-source: false the stack boots the published API image, and this
package is a composer dependency baked into it — so a PR here booted the
released version and ran the collections against that. Its own API changes were
never exercised; the check was green on code that was not under review.

overlay-package makes the reusable workflow check this repository out at the
commit under test and swap it into the running container, dumping the autoloader
(the image is built with --optimize-autoloader, so a frozen classmap would
otherwise hide classes added or moved on the branch), clearing caches and
running migrations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version bump via `flb version-bump --patch`, opening the release branch for
this cycle. Follows the standard Fleetbase flow of release branch -> main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (1c7534e) to head (25a1de1).

Additional details and impacted files
@@             Coverage Diff              @@
##                main       #88    +/-   ##
============================================
  Coverage     100.00%   100.00%            
- Complexity      1640      1742   +102     
============================================
  Files            135       135            
  Lines           7366      7699   +333     
============================================
+ Hits            7366      7699   +333     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

roncodes and others added 3 commits August 10, 2026 16:13
ci: fix the Postman contract and track the latest release automatically
ci(postman): test this branch's API code, and unpin the contract workflow
STOREFRONT_BYPASS_VERIFICATION_CODE defaulted to 999000 and was compared
against the submitted code alone, so every install shipped a live bypass that
authenticated ANY customer to anyone who knew six digits.

Scopes it to explicitly designated accounts. A code is only honoured for an
identity listed in the new review_accounts config, so app store reviewers keep
working in production while a leaked code authenticates nobody.

  STOREFRONT_BYPASS_VERIFICATION_CODE=<a secret, rotated code>
  STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100

Both values are required and the 999000 default is removed, so an unconfigured
install has no bypass at all. Comparison is constant-time via hash_equals, and
each accepted bypass is logged with its identity so use is auditable.

Applies to both call sites: verifyCode and confirmAccountClosure.

BREAKING: an install relying on the 999000 default must now set both
STOREFRONT_BYPASS_VERIFICATION_CODE and STOREFRONT_REVIEW_ACCOUNTS, or app
review logins will stop working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

Copy link
Copy Markdown
Member Author

Authorization gap in checkout — not addressed by this branch

Flagging for follow-up. Found while scoping the verification-bypass fix; it is a separate and larger change, so it is deliberately not in this release branch.

CheckoutController takes the customer as a request body parameter and never checks it against the authenticated caller:

// CheckoutController.php:169
$customerId = $request->input('customer');
...
// CheckoutController.php:188
$customer = Customer::findFromCustomerId($customerId);

Storefront requests carry two independent credentials — a store_/network_ key as the bearer, and a Customer-Token header identifying the customer (Support/Storefront.php:109, getCustomerFromToken()). Checkout consults neither when resolving the customer; it trusts the id in the payload.

Impact: anyone holding a storefront key — which is client-side by nature, it ships in the storefront app — can check out as an arbitrary customer by passing their customer_xxxxx public id. Customer public ids are exposed in ordinary API responses, so they are not a secret.

Suggested fix: resolve the customer from Storefront::getCustomerFromToken() and treat the body parameter as, at most, an assertion that must match:

$authenticated = Storefront::getCustomerFromToken();
if ($authenticated && $customerId && $authenticated->public_id !== $customerId) {
    return response()->apiError('Customer does not match the authenticated session.', 403);
}
$customer = $authenticated ?: Customer::findFromCustomerId($customerId);

Guest checkout appears to be a legitimate flow here, which is why this needs a decision rather than a blind patch: if a caller may check out with no Customer-Token at all, the body parameter has to stay supported for that path, and the check can only apply when a token is present. Worth confirming whether guest checkout is intended before changing it.

Related hardening in this branch: the verification bypass is now restricted to designated review accounts, so a leaked code no longer authenticates arbitrary customers.

Checkout took the customer from the request body and trusted it:

    $customerId = $request->input('customer');
    $customer   = Customer::findFromCustomerId($customerId);

A storefront key is client-side by nature — it ships inside the storefront app —
and customer public ids appear in ordinary API responses, so anyone holding a key
could check out as an arbitrary customer by passing their id. Neither credential
the request carries was consulted when resolving who the checkout was for.

When a Customer-Token is present it now wins, and a body parameter naming a
different customer is refused with 403 rather than silently honoured. Applied at
all three sites: beforeCheckout, createStripeSetupIntentForCustomer and
updateStripePaymentIntent.

Guest checkout is deliberately preserved. With no token the body parameter is
still used, because a guest has none to present — so this closes the escalation
without removing the anonymous flow.

The comparison normalises the prefix first. A storefront customer is stored as a
Contact and findFromCustomerId() rewrites customer_ to contact_ before looking
it up, so comparing the raw values would never match a caller's own record and
would have 403'd every authenticated checkout.

Verified against the committed logic: a caller's own customer_ id resolves
against their contact_ record, a contact_ id passed directly resolves, someone
else's id is refused, an absent body parameter falls back to the token, and both
guest paths still resolve from the body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

Copy link
Copy Markdown
Member Author

Implemented on this branch in c7c2ba2.

A Customer-Token now takes precedence at all three checkout sites (beforeCheckout, createStripeSetupIntentForCustomer, updateStripePaymentIntent), and a body parameter naming a different customer is refused with 403. Guest checkout is preserved — with no token the body parameter is still used.

One subtlety worth noting for review: the comparison normalises the prefix before matching. A storefront customer is stored as a Contact, and findFromCustomerId() rewrites customer_ to contact_ before lookup — so comparing the raw values would never match a caller's own record and would have 403'd every authenticated checkout. Verified that a caller's own customer_ id resolves against their contact_ record, while someone else's is refused.

roncodes and others added 7 commits August 11, 2026 15:45
CartController::retrieve() declared its optional $uniqueId BEFORE the injected
Request. Laravel resolves method dependencies by splicing class-typed parameters
in at their own index and filling the remainder from the route parameters, in
order — so for the route with no {uniqueId} there was nothing to place at index 0,
the Request landed at index 1, and the call arrived with a hole:

  ArgumentCountError: Too few arguments to function retrieve(), 1 passed

GET /storefront/v1/carts/{uniqueId} worked, because index 0 was filled. That is
why this read as a cart problem rather than a signature one. Five requests in the
Storefront collection failed behind it — the whole cart chain depends on
retrieving a cart first.

The unit tests never caught it because every call site passed both arguments
explicitly, matching the broken signature rather than what the router produces.
The no-identifier test now calls retrieve() with the Request alone, which is
exactly the failing shape.

Suite passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StoreController::location() used both lookups unguarded — Store::first() and
StoreLocation::first() — and handed the result straight to StoreLocationResource.
An id that resolved nothing therefore threw inside the resource:

  GET /storefront/v1/locations/{id}
  500  Call to a member function first() on null   (22 KB of HTML)

That is what a client sees for any stale or mistyped location id, and what the
contract run hit. Both cases now answer 404 with a JSON error, matching how the
rest of the controller reports a missing store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loginWithApple() verifies the token inside a try block whose catch returns
response()->apiError($e->getMessage(), 500). The JWT parser THROWS on a malformed
token rather than returning false, so any client sending a truncated, corrupted or
otherwise unparseable identityToken got:

  500 {"error":"The JWT string must have two dots"}

which is both the wrong status for client input and a leak of the parser's own
message.

Parsing is now guarded separately. A token that cannot be parsed is the same
rejection as one that parses but does not verify, so it returns the existing
400 "Apple ID authentication is not valid." and logs the parser message rather
than returning it.

loginWithGoogle already answered 400 for equally malformed input — this brings
Apple in line with it.

The existing test asserted the 500, pinning the defect; the same test asserts
Google's 400 three lines further down. That assertion is corrected and now also
checks the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewController::create() resolves the subject with
Utils::resolveSubject($request->input('subject')), whose parameter is a
non-nullable string. A request that omitted `subject` therefore threw

  Utils::resolveSubject(): Argument #1 ($publicId) must be of type string,
  null given

as a 500 — before the controller's own `if (!$subject)` guard could run. That
guard was unreachable for the commonest way to get the request wrong.

`subject` is now required in CreateReviewRequest, so a missing one is a 422 and
the existing guard handles the present-but-unresolvable case it was written for.

Surfaced by the contract run once the collection stopped sending an empty
`content` and the request got far enough to reach this line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ials

loginWithPhone() called VerificationCode::generateSmsVerificationFor() unguarded.
The Twilio SDK THROWS when no credentials are configured —
ConfigurationException, "Credentials are required to create a Client" — so
POST /storefront/v1/customers/login-with-sms answered 500 with an HTML stack
trace for any store that has simply not set up SMS. That is a configuration
state, not a server error.

The SMS attempt is now guarded and falls back to email, mirroring FleetOps'
DriverController::loginWithPhone, which has handled this since it was written.
A store without Twilio can still authenticate customers who have an email
address. The response reports `method` so the client knows which channel carried
the code; both branches were previously indistinguishable.

Note the fallback leaves two verification_codes rows — generateSmsVerificationFor
persists the code before attempting delivery, so the failed SMS attempt leaves
its row behind. Both are valid for the subject and purpose, so either verifies.
FleetOps behaves identically. Asserted rather than changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more paths where an unconfigured SMS provider surfaced as an API error
carrying the Twilio SDK's own message, "Credentials are required to create a
Client".

startAccountClosure() chose its channel with `if ($user->phone) ... elseif
($user->email)`, so a customer WITH a phone never reached the email branch: the
SMS throw went straight to the catch and returned the SDK message. The SMS
attempt is now guarded on its own and falls back to email, matching
loginWithPhone.

That fallback breaks a coupling worth making explicit. confirmAccountClosure
looks the code up by `meta->identity`, computed as `$user->phone ?? $user->email`.
Previously SMS filed it under the phone and email under the email, which agreed
only because the same precedence chose the channel. Both branches now file it
under that identity, so an emailed code is still findable for a customer who has
a phone.

requestPhoneVerification() has no email fallback — verifying a phone number by
email would not verify anything — so it returns "Unable to send phone
verification code." rather than the provider's message. Both paths report to
Sentry when bound.

Also restores `mail.manager` in the email-fallback test added in fe41cad. The
file runs in one process, and leaving the working mailer bound made a later
test's deliberately-failing email delivery succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes roncodes added enhancement New feature or request needs-docs Requires documentation updates needs-api-spec Requires API specification updates needs-human-review Requires human review before proceeding type:feature Feature or enhancement labels Aug 11, 2026
roncodes and others added 6 commits August 12, 2026 00:01
The bypass exists so App Store review can complete flows that would otherwise
need a live SMS provider. It was wired into verifyCode and confirmAccountClosure
but never into phone verification, so that flow still required Twilio outright —
requestPhoneVerification returned an error and verifyPhoneNumber could not be
reached at all.

Both ends now honour it, with the same allowlist and constant-time code check as
every other call site:

  requestPhoneVerification  a review account needs no message delivered, so the
                            send is skipped and the response reports
                            method=bypass
  verifyPhoneNumber         accepts the configured code for a listed account. The
                            bypass leaves no code row to read the phone back from,
                            so it comes from the request — which is what the
                            caller is asking to verify

A code that is not the bypass is still rejected for the same account, which the
test asserts alongside the happy path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pull request from a branch in this repo fires both `push` and `pull_request`
for the same commit, so the contract ran twice — two stack boots, two seeds, two
full collection runs for one change.

Keying the concurrency group on the COMMIT rather than the ref collapses them:
the pull_request event reports the head SHA and the push event reports the same
commit as github.sha, so both land in one group and the older is cancelled.

Deduped rather than dropping a trigger, so a direct push to a release branch is
still verified instead of relying on someone remembering to dispatch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cancel-in-progress dedupes by cancelling, which surfaces cancelled runs on the
pull request. That reads as something having gone wrong, and is a worse trade
than the duplicate run it removes.

Reverted to the previous triggers. The duplicate run on same-repo pull requests
is back, and is worth solving properly — most likely by narrowing the triggers so
only one event fires, rather than starting a run and killing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

CheckoutController reads the quote with or(['serviceQuote', 'service_quote']),
so both spellings work downstream. InitializeCheckoutRequest validated only the
camelCase one, so a snake_case request was rejected with "The service quote
field is required." before the controller ever ran — even with a valid quote id
present in the query string.

That is the spelling our own API reference documents and the one the contract
collection sends, so /checkouts/before was unreachable for delivery checkouts
via the documented payload.

Either spelling now satisfies the requirement; sending neither on a delivery
checkout still fails as before, and pickup checkouts still require neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hanges

setKey() only ever added keys. Storefront sessions are cookie-backed, so a client
that calls a network endpoint and then a store endpoint reuses the same session
and ended up with BOTH storefront_store and storefront_network set.

Every query that branches on those two applies them additively:

    ->when(session('storefront_store'), ...->where('store_uuid', ...))
    ->when(session('storefront_network'), ...whereHas('store.networks')
                                          ->where('is_available', 1)
                                          ->where('status', 'published'))

so a store-scoped request silently picked up the stricter network filter as well.
Cart::findProduct then returned null for a product that plainly belongs to the
store, and the cart answered "Invalid product provided to cart!" — which is what
the API contract run hits: List Network Stores (network key) runs before the Cart
folder (store key), and products created through the API have status NULL, so the
leaked network branch excluded them.

ProductController::find and query carry the same both-branches shape and were
mis-scoped the same way.

Each request re-runs this middleware with its own key, and setKey() is the only
writer of these session keys, so clearing the opposing scope is safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

Copy link
Copy Markdown
Member Author

API contract findings — two fixes on this branch

Both of these came out of the Storefront API contract run and are pushed here. Flagging them for your review because the second one changes session behaviour.

1. service_quote was rejected by validation even though the controller supports it

CheckoutController reads the quote with or(['serviceQuote', 'service_quote']), so both spellings work downstream — but InitializeCheckoutRequest validated only the camelCase one. A delivery checkout sending the snake_case spelling was rejected with "The service quote field is required." before the controller ever ran, with a perfectly valid quote id in the query string.

That is the spelling our own API reference documents, so /checkouts/before was unreachable via the documented payload for delivery checkouts.

Either spelling now satisfies the requirement. Sending neither on a delivery checkout still fails; pickup still requires neither.

2. The storefront session kept both scopes when the key type changed

SetStorefrontSession::setKey() only ever added keys. Storefront sessions are cookie-backed, so a client that calls a network endpoint and then a store endpoint reuses the same session and ends up with both storefront_store and storefront_network set.

Every query branching on those applies them additively:

->when(session('storefront_store'), fn ($q) => $q->where('store_uuid', session('storefront_store')))
->when(session('storefront_network'), function ($q) {
    $q->whereHas('store.networks', ...);
    $q->where('is_available', 1);
    $q->where('status', 'published');
})

So a store-scoped request silently picked up the stricter network filter too. Cart::findProduct() returned null for a product that plainly belongs to the store, and the cart answered "Invalid product provided to cart!" — with both ids resolving in the URL, which is what made this look like a fixture problem rather than a scoping one.

That is exactly the contract run's shape: List Network Stores (network key) runs before the Cart folder (store key), and products created through the API have status NULL, so the leaked network branch excluded them. ProductController::find and query carry the same both-branches shape and were mis-scoped identically.

setKey() is the only writer of these session keys and each request re-runs the middleware with its own key, so clearing the opposing scope is safe.

Worth your judgement: if any client depends on a network key and a store key being layered on the same session on purpose, this changes that behaviour. I found no code path that reads one while the other is authoritative, but you would know better whether storefront-app or the console relies on it.

@roncodes

Copy link
Copy Markdown
Member Author

Unfixed, needs a decision: a product created via the public API can never be published

Not touched on this branch — the fix depends on which side you consider correct.

CreateProductRequest allows three statuses:

'status' => 'nullable|string|in:draft,active,archived',

But published is the status the rest of the codebase filters on:

  • Models/Product.php:25public const PUBLISHED = 'published'
  • Models/Product.php:457, Http/Controllers/v1/ProductController.php:351,385, CategoryController.php:53,101, StoreController.php:230where('status', 'published')
  • Models/Cart.php:709 — the network branch of findProduct()
  • Http/Controllers/v1/CheckoutController.php:166 — cart validation at checkout
  • Http/Controllers/ProductController.php:110 (internal/console) — writes 'status' => 'published'

So the console creates published products and the public API cannot. status is also nullable with no default, so an API-created product starts as NULL.

Consequences:

  1. Network-scoped storefronts never see API-created productsProductController::query/find require status = 'published' under a network key.
  2. Checkout rejects themCheckoutController:166 looks up cart items with ->where('is_available', 1)->where('status', 'published'), so an API-created product in a cart is not found at capture time. This one bites store-scoped merchants too, not just networks.

Three ways I can see to close it, and I'd rather you pick:

  • Add published to the allowed list and default status to published on create — matches what the console already does, and makes the public API a first-class path.
  • Change the filters to active — if draft,active,archived is the intended vocabulary and published is legacy, the read side is what's stale.
  • Map on write — accept the documented vocabulary and translate activepublished internally. Keeps the public contract as-is but leaves two vocabularies in the schema.

Whichever we choose, the contract collection currently sends no status on Create Product, so it exercises the NULL path.

@roncodes

Copy link
Copy Markdown
Member Author

Unfixed, minor: CartController::retrieveCart() passes its flag into a differently-meaning parameter

Spotted while tracing the cart failure. Not changed here — it alters mutation behaviour and deserves a deliberate call.

// CartController.php:12
protected function retrieveCart(?string $uniqueId, bool $create = false): Cart
{
    return Cart::retrieve($uniqueId, $create);
}

// Cart.php:647
public static function retrieve(?string $id, bool $excludeCheckedout = true): Cart

The flag is named $create at the call site but lands in $excludeCheckedout. Effect:

  • retrieve() (the GET action, line 36) passes true → excludes checked-out carts. Correct, by coincidence of the name.
  • add, update, remove, empty, delete (lines 53, 77, 97, 115, 129) all take the default false → they will happily mutate a cart that has already been checked out.

So after a checkout captures an order, the same cart_* id still accepts item additions and removals. I don't know whether storefront-app relies on that (e.g. re-ordering from a past cart), which is why I've left it alone.

If checked-out carts should be immutable, the fix is to drop the passthrough flag and let Cart::retrieve() keep its true default. If the current behaviour is intentional, the parameter should at least be renamed so it stops reading as "create".

roncodes and others added 5 commits August 13, 2026 14:15
…read

`published` is the status the rest of the module reads — Product::PUBLISHED, the
network branch of Cart::findProduct(), ProductController::find/query,
CategoryController, StoreController, and CheckoutController's cart validation all
filter on it, and the console controller writes it.

CreateProductRequest allowed only draft, active and archived, and `status` is a
nullable column with no default, so a product created through the public API
landed as NULL and could never satisfy any of them:

  - a network storefront never listed it
  - CheckoutController:166 looks cart items up with
    ->where('is_available', 1)->where('status', 'published'), so it was dropped at
    capture time for store-scoped merchants too

Allow `published` and default to it on create, matching the console. UpdateProductRequest
is an empty subclass, so the one rule edit covers both. An explicit status is still
honoured.

No migration needed: existing rows are already `published` (console) or NULL/draft
(API), and nothing that reads `published` gets stricter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
retrieveCart() named its second parameter `$create` and passed it straight into
Cart::retrieve(), whose second parameter is `$excludeCheckedout`. The GET action
passed true; add, update, remove, empty and delete all took the false default and
so operated on carts that had already produced an order.

The name made that read as deliberate. Drop the passthrough and let Cart::retrieve()
apply its own `true` default everywhere.

Behaviour change worth noting in the release: a cart id whose checkout produced an
order is no longer found, so Cart::retrieve() falls through to newCart() and the
client gets a fresh empty cart rather than editing a historical one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create() read `identity` and nothing else. A body of {name, email, code} — which is
what the documented flow produces after verifying an email — left it null,
static::phone() turned that into the literal '+', and the lookup on meta->identity
could never match. A correctly issued code came back "Invalid verification code
provided!".

Fall back to the payload's own email, then phone, before giving up.

static::phone() now returns null instead of a bare '+' when there is nothing to
format. That '+' was also being written into contacts.phone and users.phone for
every customer created without a phone number.

Returning null makes `where('phone', $x)` compile to `phone IS NULL`, which would
match an arbitrary phone-less user, so the four lookups that could receive it are
guarded: login, loginWithPhone, verifyCode and requestPhoneVerification now bail
with their existing error rather than searching on null.

Also guarded verifyPhoneNumber's $verificationCode->meta['phone'] subscript — a row
written by anything other than requestPhoneVerification turned a recoverable 400
into a 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PHP CI came back red on the previous push. Two causes, both mine:

  - the new create-customer test inserted a `uuid` column into the throwaway
    verification_codes table it builds, which has no such column
  - Product::PUBLISHED does not resolve under the unit-test harness — reproducible
    on a whole-suite run, not just an isolated file — so ProductController::create
    now writes the status literally, as the rest of that controller already does

Full suite verified locally under PHP 8.2: 0 failures.

Worth recording for anyone debugging this suite: the pest runner emits no output at
all here, in CI or locally, and reports only an exit code. `--log-junit` is the way
to see which tests failed and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /checkouts/status matches on public_id AND token:

    Checkout::where('public_id', $checkoutId)->where('token', $token)->first()

but the chkt_* public id and the checkout_* token are different values, and only
initializeQPayCheckout was returning the id. The cash, card and payment-intent-update
paths returned the token alone, so a client following the documented flow had no way
to learn its own checkout's id and the status endpoint was unreachable for them.

Add `checkout` to the three responses that omitted it, mirroring what the QPay path
already does. Purely additive, so no existing client breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov flagged the branch at 99.94% project / 99.14% patch. All four uncovered
statements were guards added with the static::phone() change, and each one protects a
lookup that would otherwise search on null:

  loginWithPhone            where('phone', null)          -> `phone IS NULL`
  verifyCode                phone = $x OR email = $x      -> both IS NULL
  requestPhoneVerification  findExistingUserByPhone(string) -> TypeError on null
  verifyPhoneNumber         $verificationCode->meta['phone'] -> 500 instead of 400

Each is now exercised through the controller, asserting the error it returns rather
than merely executing the line.

Two of them need care that is worth recording:

  * verifyCode's case binds its request instead of only passing it. static::phone()
    falls back to request()->input('phone'), so an earlier bound request carrying a
    phone supplied an identity the call never sent, and the guard stayed unreached. It
    runs last in that test so the rebind affects nothing after it.
  * verifyPhoneNumber's case seeds a storefront_verify_phone row with an empty meta,
    which is the only way to reach a matched code that carries no phone.

Local: 100.00% statements (7160/7160), whole suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-api-spec Requires API specification updates needs-docs Requires documentation updates needs-human-review Requires human review before proceeding type:feature Feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant