AUTOXSPARE Docs
Changelog

Technical changelog

Every commit, with the traps found along the way.

The engineering view of What's new. Newest first. Hashes are from main in VIGOR-Digital-Solution/autoxspare-2.

29 October 2026 — One parcel weight

  • /store/shipping-quotes had its own copy of the weight rules (1000 g assumed, 30 kg cap) while the EasyParcel provider used 2000 g and 70 kg, so the courier chooser and shipping_methods[].amount disagreed for every cart under 2 kg and every part with no weight. parcelGrams() is now exported from modules/easyparcel/service.ts and both use it; covered in reliability.unit.spec.ts.
  • Staff guides: the admin Refund runs refundPaymentWorkflow, which refunds at Curlec. The guide said to refund in Curlec and then "record" it in the admin, which is two real refunds. order-canceled.ts refunds captured payments itself, contrary to the old Orders page.

28 October 2026 — No storefront previews

  • apps/storefront/vercel.json sets ignoreCommand to skip Preview builds (exit 0 skips, so the test is = "preview", not !=). Vercel's Preview environment has no variables, so every PR build failed in under a second on NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY. Do not fix that by copying the Production values: a preview would then run against the live backend and live Curlec. Restore previews once a staging backend exists.
  • Vercel does deploy main to production from git (productionBranch=main), just like Railway. It finishes about five minutes before Railway, so a change where the storefront needs a new backend route must ship the backend first.

6 September 2026 — Audit repairs

  • Curlec returns resolve the original cart through POST /store/curlec/return, which validates the API-key HMAC and gateway-order/session amount, currency and provider association. Core cart completion never reinitializes the session. Provider authorization queries payments on the original gateway order and accepts only matching authorized/captured MYR payments. Gateway reads preserve data.id as the gateway order id.
  • EasyParcel OAuth requires an admin session on both routes. Random state has a ten-minute cache TTL, is bound to the user and browser session, and is consumed under a shared lock. Callback text is HTML-escaped.
  • Fulfillment ignores caller courier overrides. Quote caching includes parcel value. Token refresh re-reads inside a transaction/row lock in the existing token store, coordinating separate clients and workers without a new table.
  • Aftercare writes run in workflow steps. Product votes use a hashed receipt ledger and count in one write, protected by product/order locks. The order mirror is repaired on retry. Return requests persist before notification and use stable notification keys; the mail provider receives the same idempotency key. Failed notification retries retain their original row id to avoid Medusa 2.19 assigning a fresh id to an existing idempotency key; interrupted pending rows become retryable under the per-request lock.
  • Catalogue pages already render dynamically (request cookies and search params). Removed unused build-time catalogue enumeration: builds no longer require a live Medusa database just to discover dynamic product/category/collection URLs. The docs use the existing system font stack instead of downloading Google Fonts.
  • Storefront type errors are no longer ignored. Explicit immediate cache expiry works in actions and route handlers. Storefront React type resolution stays inside its workspace; backend React 18 aliases remain intact. Root tests run the backend suite; CI runs typecheck, lint, tests and backend/storefront/docs builds.
  • Existing synchronous state updates in effects remain lint warnings, not suppressed diagnostics; hook correctness and render-time ref checks remain errors. This remains UI cleanup, not a claim that the codebase is lint-clean.

Validation and release work are tracked in docs/audit-todo-2026-09-06.md. No stock counts or weights were guessed, and no paid test shipments were booked.

3 September 2026 — Courier choice at checkout

The courier must be the option, not data on the method

apps/backend/src/scripts/setup-courier-options.ts

The first attempt carried the chosen courier on the shipping method's data.service_id. It cannot work, and it fails silently.

addShippingMethodToCartWorkflow resolves the amount through listShippingOptionsForCartWithPricingWorkflow using only option_ids (add-shipping-method-to-cart.js:66-78). The data posted with the method reaches validateFulfillmentData afterwards and is stored on the method at line 116, while amount was already taken from shippingOption.calculated_price at line 113. So the courier was recorded accurately and priced not at all.

calculatePrice(provider_id, optionData, data, context) does receive the option's own data column, so the courier lives there instead: one calculated shipping option per courier, data: { service_id }, eight of them.

Nothing about this is visible in a response. The method stored the right service_id, the option was right, the quote endpoint was right. Only comparing the quoted price against cart.shipping_methods[].amount exposed it — picking Pos Laju at 12.09 charged 8.60.

Availability cannot come from the option list

Options are configured per region; courier coverage is per postcode. Measured: Penang and KL return 18 quotations, Kota Kinabalu 12, Kuching 13.

An option whose courier does not serve the destination cannot simply throw — fulfillment-module-service.js:759 prices every option in one Promise.all, so one rejection loses them all. It returns the cheapest available rate instead and the storefront filters it out, using /store/shipping-quotes, which returns only genuinely available couriers. That endpoint now resolves each quotation to its option_id, because option.data is not exposed on /store/shipping-options — verified, it comes back null — so the storefront has no other way to tell which option is which courier.

Nine options, one API call

Per-courier options mean Medusa prices nine options per cart, which was nine identical EasyParcel quotations. quoteFor caches per from:to:grams:parcelValue for 60s and caches the promise, not its result, so calls arriving together collapse into one request rather than racing to fill the same slot. A rejection evicts the key so an outage is not pinned for the whole TTL.

validateFulfillmentData had to be taught the courier

Once the courier became the option, the storefront stopped sending a service_id, so returning the method data unchanged would have had createFulfillment submit an empty service and let EasyParcel choose — priced as one courier, shipped as another. It now copies service_id off optionData.

Service ids are stable across routes

Confirmed against Bayan Lepas, Kuala Lumpur, Johor Bahru, Kota Kinabalu, Kuching and Jitra before being pinned in the script. Pickup variants are preferred over drop-off at equal price; EP-CS0MO (Lazada) and EP-CS09Q / EP-CS0MP (Celsius) are drop-off only and excluded.

PaymentActions has no refund member

apps/backend/src/api/hooks/curlec/route.ts

/hooks/payment/:provider maps webhooks onto PaymentActions, whose members are authorized, captured, failed, pending, requires_more, canceled, not_supported and pending_authorization. A refund cannot be expressed, and PaymentEvents only emits payment.captured / payment.refunded, so nothing fires on failure either.

Both needed a container, which a provider service does not get, so they live in an API route with bodyParser: { preserveRawBody: true } declared in middlewares.ts - the signature is over the exact bytes sent.

A refund is reported, never replayed. Recording it against the payment would run refundPaymentWorkflow, which asks the provider to refund - a second, real refund. The order is annotated and the shop emailed instead.

react was a devDependency

medusa build writes .medusa/server/package.json from declared dependencies only, and npm install --omit=dev skips devDependencies. React was present in production purely transitively - and --legacy-peer-deps, added the same day, stops npm installing peers. One dependency bump from every template failing with Objects are not valid as a React child, inside a subscriber, silently.

scripts/check-emails.ts renders all eight in the production runtime without sending, and reports the React version so the two-copy case is visible.

/wallet-balance 404s on 2026-06

The token-refresh job originally probed walletBalance(). That endpoint is not available on this API version, so the job would have emailed "live rates are OFF" every morning while rates were fine. It probes with a real quotation now, which also exercises the exact call checkout depends on. Wallet balance is best effort and never raises an alert.

Also corrected: the refresh token runs to 2027-09-03, not 30 days.

A 401 is not a 500

lib/util/session-expired.ts

The starter routes every failed customer call through medusaError, which throws - so an expired token rendered "This page couldn't load" on /account/orders and "An error occurred, please try again" on the profile form. Retrying could not work: the stale token stayed in the cookie. A 401 now clears it and redirects to the account page.

JWT_SECRET and COOKIE_SECRET are both set on Railway, so this was a genuine expiry rather than redeploys invalidating sessions - worth checking first, because the symptom is identical.

One Skeleton, 76 placeholders

apps/storefront/src/modules/common/components/skeleton/index.tsx

shadcn's Skeleton is one div and two utility classes, so it was rebuilt on this shop's bg-grey-10 rather than installed - there is no Radix or cmdk behind it to justify adding components.json. clx from the existing UI barrel is already clsx + tailwind-merge, which is what shadcn's cn is.

The first sweep was a regex over <div className="...animate-pulse...">, and it converted wrapper divs that merely carried the animation for their children into <Skeleton> while leaving their </div> behind - broken tags in six files. The starter nests placeholders under an animated parent, so "has animate-pulse" does not mean "is a placeholder".

The rule that works: convert only leaf elements - <div className="..." /> and <div className="..."></div> - and separately strip animate-pulse from wrappers, since each placeholder now animates itself. Verify with a grep for <Skeleton lines lacking /> before building.

Also reverted cleanly first (git checkout -- modules/skeletons) rather than patching the damage, which is the cheaper move when a codemod misfires.

Combobox and courier logos

The courier picker reuses SearchableSelect, the Headless UI combobox the fitment selector already uses - listbox ARIA, type-ahead and keyboard nav already solved - rather than adding a second one. Its Option type grew an optional icon URL, rendered as a plain <img> with empty alt (the courier's name sits beside it).

Logos come from courier_logo on the quotation, which /store/shipping-quotes was already passing through; all nine verified 200 from s3-ap-southeast-1.amazonaws.com. Plain <img> rather than next/image so no remote pattern has to be configured for a third-party host.

The deploy failure was npm install, not the build

railway.json

Six consecutive railway up failures, initially misdiagnosed as build memory twice. Railway's buildLogs over GraphQL showed Frontend build completed successfully (24.93s) immediately followed by npm error code ERESOLVE — the failure was npm install --omit=dev inside .medusa/server, where the react-email packages conflict against @medusajs/framework@2.19.0. --legacy-peer-deps fixes it.

The CLI's tail output is not reliable here — it showed an older successful deployment's log. Fetch buildLogs by deployment id over GraphQL instead of trusting what railway up prints. A later TLS handshake timeout reaching registry-1.docker.io was genuinely transient and passed on retry.

tsconfig.tsbuildinfo was tracked

A git stash refused to pop because the build cache changed underneath it, stranding the work in the stash. Now in .gitignore and removed from the index. Recover with git checkout -- <artifact> then git stash pop.

1 September 2026 — Go live

apps/storefront/src/modules/garage/components/garage-link/index.tsx

The garage lives in localStorage, so CategoryFilter (a server component) built plain /categories/<handle> hrefs with no vehicle= params. The listing code was correct throughout - adding the params by hand returned 11 correctly filtered products - but every sidebar and sub-category link silently discarded the car, while the header chip kept asserting it was applied.

GarageLink appends garageQuery(garage) client-side. Until the garage is read ready is false and the bare href is used, which errs toward showing more parts rather than fewer.

Anything navigating into a listing must use GarageLink, not LocalizedClientLink, or it will drop the vehicle again. A filter the page claims to apply and does not is worse than no filter.

Known remaining inconsistency: sidebar counts are global (Suspension 1,539) and do not reflect the active vehicle, so they disagree with the result count. Making them vehicle-aware costs one extra count query per group.

Unknown handles returned 500 instead of 404

getImagesForVariant(pricedProduct, ...) ran before the if (!pricedProduct) notFound() guard and dereferenced undefined. Confirmed in the Vercel runtime logs as TypeError: Cannot read properties of undefined (reading 'images'). Moving the guard above the call fixes it.

Separately, only 83 Shopify collections are mapped in legacy-redirects.ts; unmapped ones fell through to /my/collections/<handle>, a route the storefront does not serve. resolveLegacyPath now falls back to a search, and maps Shopify's nested /collections/x/products/y shape too.

5064623 — the admin login, this documentation site, and the backend URL fix:

  • Admin login rebranded — apps/backend/src/admin/widgets/login-branding.tsx
  • This documentation site — apps/docs, Fumadocs on Next 16
  • MEDUSA_BACKEND_URL repointed to https://api.autoxspare.com so the admin calls the origin it is served from

Medusa 2.19 has no supported way to replace the login route: it is compiled into @medusajs/dashboard and the copy comes from that package's i18n bundle. What it does expose is a login.before zone rendering inside the login column, so the widget supplies AUTOXSPARE branding and hides Medusa's. It finds Medusa's markup by walking up from its own ref and hiding every sibling of the form wrapper, rather than matching Tailwind class names that change between releases.

The screen is pinned to light because the wordmark is black on transparent and vanishes on the dark theme. Colours are scoped to the login shell rather than written to medusa_admin_theme, so the rest of the admin keeps the user's choice.

Committed the same day:

HashChange
a3d2bcfScript to diagnose admin logins
59f6fecStop showing React internals to someone trying to pay
79d530aMYR only, and show the gateway mode in the admin
cb6816bSwitch gateway and courier between test and live with one variable
461dae4Link the Jitra warehouse to the sales channel, round the fitment fields
763a4d2One Malaysia-wide shipping option, not two
8019463Give Malaysia somewhere to ship from
6a22c5eStop the product page promising two contradictory things; fix terms layout
15d528cMake Curlec the only way to pay; remove the Medusa favicon
2fec830Style the store sidebar, and give search a type-ahead

enforce-myr.ts matters more than it looks: the seed left supported_currencies as EUR + USD, so the admin offered currencies that would save on a price list and then never apply to anything.

31 August 2026 — Redesign and integrations

HashChange
1bead07Never show an empty shelf when the garage filter misses
7f7e6e6Google, Apple and Facebook sign-in
17edcbdMake the homepage ask the only question that matters
c5c12abWire Curlec into checkout, drop the European payment methods
4f7276dEasyParcel fulfillment provider
2083d1eCurlec payment provider
60d0124Recover the parcel weights the import dropped
5afa249Spec the Curlec and EasyParcel integrations
6edc967Handle bracketed part numbers, give a dead search somewhere to go
8053c2cMake the search box actually search
45b4124Let the fitment selector follow the URL
e145439Script to repair the vehicle fitment data
aa2bdd3Make the fitment selector searchable
ae8b027Fix what the accessibility and mobile audit turned up
916e142Take the Medusa and Next.js branding out of the shop
8f47724Rebuild the product page against the Shopify theme
9d55a7dRebuild the homepage against the Shopify theme

updateProductTags(id, { metadata }) does not work — the DTO only carries value. Use updateProductTagsWorkflow from @medusajs/core-flows.

Search was routed through a server route (/api/search/suggest) rather than the browser calling Medusa directly, because STORE_CORS did not list the live storefront domain and a browser fetch died at preflight with a bare "Failed to fetch" — which renders as "no results" rather than as an error.

18 August 2026 — Storefront depth

HashChange
acc86cdDelete a customer's login too; stop trusting tokens for deleted customers
3414095 70230d9Cleanup script for migration test debris, armed by env var
8f48880 f4a2188Carry Shopify's stock status across, label pre-orders
8a162b1Let customers browse past the first 100 products
dd2b0eeBring the store page up to the Shopify collection page
bb4a1b4Serve resized images instead of full-resolution sources
15d19acShop megamenu
547f34a 4a29e75Parent categories show their products; Bestseller tabs
3b89a22Preserve search rankings across the Shopify cutover
ae17b8e 2bc83b4Card hover tray, product rails, compare, hero carousel
190066bMove the storefront to Singapore
f03bdd9 c805bd9Tie the wishlist to the customer account, merging guest saves
b134e5fStop product titles colliding with their price on phones
2b8283e 78069b4Restore store filters; native sort control on phones
2e18c18 c77d7daMobile tab bar, wishlist fetched server-side
4364477Contact, terms and privacy pages

190066b is worth knowing about: the storefront was defaulting to Vercel's US East region, costing roughly 5× the TTFB for Malaysian visitors. It is pinned to sin1, and that is dashboard configuration, not in the repo.

17 August 2026 — Images and theme

HashChange
05451fbStop the cart wordmark clipping off the mobile header
6555547Brand the mobile drawer, drop the last Shopify CDN reference
582034dFix mobile: restore search, compact the fitment bar
047acafReplace the Medusa default footer
054836eMatch the Shopify theme: brand tokens, header, hero, fitment bar
552bbe3Rebuild the storefront homepage around fitment search
c6e54ea 862eb83 9786c39 95f3707Thumbnail repairs
fd4bf50Drop cache: force-cache so next.revalidate governs freshness
72b2e27Allow the R2 hostname in next/image remotePatterns
85824ceMove product images to Cloudflare R2, repoint 8,708 references

product.thumbnail is stored separately from product.images. Updating images alone leaves the storefront on the old host, and it must be cleared with '' — Medusa ignores null.

15–16 August 2026 — Foundation

HashChange
e3d021eAudit infra, remove redundancy, document traps in AGENTS.md
6aa0cbfFix storefront caching: stale catalogue, uncacheable 7.4MB fetch
bcf83c9Park the Railway image map; Railway buckets cannot serve public URLs
8175c52docker-compose for local dev, project README
98dfb43Convert Shopify HTML descriptions to plain text on import
0dc6c86Native-Medusa Shopify importer: 4,445 products, 3,299 fitment links
673756bShopify import parser and dry-run report
a0937fd 3412bb8Deploy config; Vercel root directory is apps/storefront
dbde6f2 13cc4d3Redis module registration and production Postgres SSL
0006225 73c7227 19ff201 7cf9c14Shopify audit: a launch, not a migration
10b83ffFix blank admin (React hoisting), upgrade storefront to Next 16
6571c43Scaffold the Medusa v2.19 monorepo
925d902Initial commit

Deployment traps

Each of these cost a failed build or a wrong diagnosis:

TrapDetail
medusa develop skips typecheckBackend type errors only fail the Railway build. Run npm run build in apps/backend first.
Admin renders blanknpm hoists React 19 over the admin's React 18; fixed with a Vite alias in medusa-config.ts.
Shipping options come back emptyThe sales channel must be linked to the stock location with linkSalesChannelsToStockLocationWorkflow. Nothing errors; the options are simply absent.
Vercel needs a team scopeBare vercel --prod returns "Not authorized"; pass --scope vigor-digital-solution.
Two Vercel projects, one repo rootThe root .vercel link points at the storefront, so deploy the docs with an explicit VERCEL_PROJECT_ID. Neither project has a git integration — git push deploys nothing.
vercel env add defaults to sensitiveA sensitive NEXT_PUBLIC_* cannot be read at build time; the build silently ships the fallback.
Railway CLI token~/.railway/config.json has both token (null) and accessToken. Reading token yields Not Authorized, which reads like a permissions problem. The token also expires — railway whoami refreshes it.
Custom domain targetPortCan be created null, in which case Railway's edge returns Application not found.
Certificate stuck "validating"Check port 80 first. If http:// returns 200/301 while https:// fails, routing is fine and only the cert is missing. If port 80 also fails, the edge does not know the host and a cert can never issue.

Keeping these docs current

These docs are part of the work, not a write-up afterwards. When a change lands:

  1. Add a plain-language entry to What's new — what someone running the shop would notice, not what the code does.
  2. Add the engineering detail here, with the commit hash.
  3. Update the API reference if a route, payload or environment variable changed.
  4. Update the user guide if a screen or workflow moved.

A change that alters behaviour but ships no docs is unfinished. If a fix later turns out to be wrong, correct the original entry rather than appending a contradiction — a changelog that argues with itself is worse than none.

On this page