From 4ea7e515532e2e4d61a8251a8a3b58762abac70d Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 22:07:45 +0800 Subject: [PATCH] feat: let any whole number from the minimum order up be ordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner decided what a quantity must be: at least the minimum order, at most what a line may hold, and not a multiple of the minimum (design §11, decision 12). The server never asked for a multiple. The quantity fields did, by stepping in units of the minimum, so a browser refused what the server would have taken — and with a ceiling of 500 a part with a minimum of 200 could be ordered as 200 or 400 and nothing else. They step by one. The design also records the owner's other decision of the day: a derived price goes on being rounded up to the next .99 (decision 11). --- CLAUDE.md | 4 ++-- SECURITY.md | 2 +- .../2026-09-30-nundar-on-mallok-design.md | 12 +++++++++--- scripts/smoke-shop.mjs | 4 ++-- src/plugins/shop/plugin.json | 2 +- src/plugins/shop/routes/cart.ts | 7 ++++--- src/theme/layouts/shop-cart.liquid | 6 +++--- src/theme/partials/offer.liquid | 7 ++++--- test/shop/cart-route.test.ts | 18 ++++++++++++++++++ test/theme/cart.test.ts | 12 ++++++------ 10 files changed, 50 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11b0271..83d20af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - Templates are restricted Liquid. Output is escaped; only `content.html` and `page.head` are emitted verbatim. `page.head` carries hreflang and structured data from Mallok and must stay in `layouts/base.liquid`. - Interface strings are in `locales/*.json` (flat maps, the default locale is the fallback). Site-specific copy — the home page's headline and sections, the footer, the links behind the buttons — is a theme option, never a string in a template; per-language option values go under `themeOptions.$locales` in `site.json`, and `test/project.test.ts` fails when a language is left without one. The tagline is not an option: it is Mallok's own setting, a map of language to text in `site.json`. It follows the site's name in the home page's title, and describes that page unless the `home_description` option gives the language a fuller text. - **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. Every kind with a `base` has a `listLayout` here, because each list is a page worth having; a kind without one has no list page — its base path answers 404 — and no entry in `site.kinds`. A template links to a kind's list only through `site.kinds.`, inside `{% if %}`: the breadcrumb (`partials/crumbs.liquid`) and the home page's link to the catalogue do, and `test/theme/unlisted.test.ts` holds both on a site that serves products without a list. -- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`), and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. Below the cart it draws the form that sends it as a request for a quote, when `plugin_page.can_inquire` says the cart can go as it stands, and above it what became of one that was sent (`plugin_page.sent`, `plugin_page.inquiry_problem`). The form's words are the pack's `quote_*` keys — not `inquiry_*`, which are Mallok's inquiry form's and must stay out of the default pack. Each field carries the plugin's own rule as `required`, `maxlength` and `pattern` — the address's and the name's are the very strings the server compiles (`EMAIL_PATTERN_SOURCE`, `NAME_PATTERN_SOURCE`) — and `test/theme/cart.test.ts` holds those attributes to the constants in `lib/inquiries.ts`: a refused form comes back empty, so a browser has to refuse what the server would, and the server has to take what a browser lets through (a tab in a name, a line break counted once). The field named `website` is one no person may see or reach. +- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`) and steps by one — the minimum is a floor, not a pack size, by the owner's decision — and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. Below the cart it draws the form that sends it as a request for a quote, when `plugin_page.can_inquire` says the cart can go as it stands, and above it what became of one that was sent (`plugin_page.sent`, `plugin_page.inquiry_problem`). The form's words are the pack's `quote_*` keys — not `inquiry_*`, which are Mallok's inquiry form's and must stay out of the default pack. Each field carries the plugin's own rule as `required`, `maxlength` and `pattern` — the address's and the name's are the very strings the server compiles (`EMAIL_PATTERN_SOURCE`, `NAME_PATTERN_SOURCE`) — and `test/theme/cart.test.ts` holds those attributes to the constants in `lib/inquiries.ts`: a refused form comes back empty, so a browser has to refuse what the server would, and the server has to take what a browser lets through (a tab in a name, a line break counted once). The field named `website` is one no person may see or reach. - **`plugins.shop` is optional everywhere.** It is absent when the plugin is off, when its read failed, in `mallok build` and in the admin's preview, and a product may have no variants. A template wraps what it prints from it in `{% if %}`, and the page is whole without it: the sizes with their SKUs, and no prices. A variant is found by walking `plugins.shop.variants`, never by `variants[sku]`: Liquid answers `size`, `first` and `last` on any collection itself. The words around a value — "Unit price", "In stock", "business days" — are the theme's, in its packs; `avail_` names the three states. - **A product is one page with its sizes on it**, not a page per size. `facets` are the attributes a buyer filters by; `sizes` maps each SKU to what distinguishes it; `specs` is the full table. `partials/spec-table.liquid` (the specification finder, the catalogue, a collection's products, a product's neighbours) takes its columns from the first product that has `facets` and fills every row by attribute name, so every product in a language must use the same names. Below 72rem the same table is laid out as cards, two to a row on a tablet: seven columns need about 1100px in German. - **References resolve by slug within the same language.** An `application` and a `case` name their `product`; the product page lists them through `content.backrefs.application` and `content.backrefs.case`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`. @@ -129,7 +129,7 @@ When one is built, take it off this list and prove the behaviour with a test or - A page is in one currency. A currency is offered only when every priced variant on the page has a price in it (`currenciesFor`). Where there is none the page prints no price, and its structured data offers none. - The structured data offers what the page prints and nothing else: the same variants, the same amounts, the same states (`offersFor`; the test compares the two digit for digit). - A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once. -- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order, steps by it and stops at the ceiling (`min`, `step`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form. +- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. Between the two any whole number may be ordered: seventeen of a part whose minimum is ten (design §11, decision 12). The quantity field on a page starts at the minimum order and stops at the ceiling (`min`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form. - A refused change writes nothing and creates no cart; a sum is stated only for a cart every line of which can be ordered as it stands. - A redirect is judged by the path the browser will read, not the text that was sent: `/.//host` is one slash as typed and another site once resolved. - `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write relies on the constraint. diff --git a/SECURITY.md b/SECURITY.md index ebbc0a6..56905bb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,7 +57,7 @@ These are deliberate and should not be "simplified" away: - **Prices are never accepted from a client.** The cart stores quantities only; every amount is recomputed from the database when the cart is priced. -- **MOQ and stock are enforced on the server.** The form's `min` and `step` are +- **MOQ and stock are enforced on the server.** The form's `min` and `max` are a convenience. A direct POST is refused with the same rules, and a test posts one. - **Stock cannot go negative.** The column carries `CHECK (stock >= 0)`, and a diff --git a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md index bc2fc25..05ca572 100644 --- a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md +++ b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md @@ -381,6 +381,12 @@ Decided later: 10. **The inquiry cart (2026-10-09).** - Decision: the shop plugin builds it — a table of its own, a form on the cart page, an email to the seller (§17, left open, item 1; built in §18). - Mallok's inquiry plugin stays what it is: the form on a content page. The two are separate stores, read in separate panels of the admin. +11. **The rounding of a derived price (2026-10-09).** + - Decision: the rule stays as it is — a converted price is rounded up to the next `.99`, or to the next whole unit where a shop sets that (2026-09-03 §4.4.4; §17, left open, item 3). + - A catalogue of parts priced at a dollar or two is served by prices entered by hand, which are never recomputed, as the sample's are. +12. **What a quantity must be (2026-10-09).** + - Decision: at least the minimum order, and no more than a line may hold. Not a multiple of the minimum: with a minimum of ten, seventeen can be ordered (§17, left open, item 8). + - This replaces the stepping of 2026-09-03 §4.5.1, where the quantity field moved in steps of the minimum. The server never required a multiple; the field's `step` did, so a browser refused what the server would have taken — and, with a ceiling of 500, let a part with a minimum of 200 be ordered as 200 or 400 and nothing else. The field steps by one now. ## 12. What phase 1A changed or found @@ -625,12 +631,12 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con 1. *The inquiry cart.* Decided on 2026-10-09: the shop plugin's (§11, decision 10), and built (§18). 2. *Measurements on a real account*, and whether purges evict (the table above). -3. *The rounding rule and unit prices.* A derived price is rounded up to the next `.99` or whole unit (2026-09-03 §4.4). For a part priced at two dollars that turns $1.60, $1.85 and $2.05 alike into €1.99. The rule is the owner's and is unchanged; a catalogue of small parts wants a third strategy that rounds to the cent, or hand-entered prices, which is what the sample uses. +3. *The rounding rule and unit prices.* Decided on 2026-10-09: the rule stays (§11, decision 11). 4. *Stock set in the admin is a figure, not a difference.* When the seller types one, a payment that lands between opening the form and saving it is overwritten by it. The ledger stays true — it records the difference from the stock as it was when the write landed — but the seller is not told. A save that names no figure touches no stock. 5. *The not-found page has no cart link*: it waits for Mallok (written up for it). 6. *The cart page's forms are not rate-limited beyond Mallok's relaxed tier* (120 a minute per visitor per route), which is best-effort by Cloudflare's own description. 7. *Ten thousand to a line.* Decided on 2026-10-09: five hundred (§11, decision 9). -8. *The step is the browser's.* A quantity field moves in steps of the minimum order (2026-09-03 §4.5.1); the server enforces the minimum and accepts any quantity above it. Whether multiples are a rule or a convenience is not written down. +8. *The step is the browser's.* Decided on 2026-10-09: a quantity is any whole number from the minimum order up, and the field steps by one (§11, decision 12). 9. *A price of nothing.* The admin refuses one; a row with one, from a seed or by hand, is shown and can go in a cart, where it totals zero — which meets §14's open item 4 when checkout is built. 10. *Deletions the plugin missed.* If the delete hook fails, or the plugin is off when a product is deleted, its variants stay: on no page and in no order, with their SKUs taken. Nothing tells a plugin afterwards. @@ -692,7 +698,7 @@ Not changed: the export still takes up to a thousand inquiries, as Mallok's own 3. *The shop's data is not in Mallok's export.* Closed the same day: §19. 4. *A job is queued beside a conditional write that stored nothing* in a true race, and finds nothing to send. Mallok's statement takes no condition. Written up. 5. *An amount is stored twice* because a table panel has no money column. Written up. -6. *Whether a quantity must be a multiple of the minimum order* (§17, left open, item 8) and *the rounding of a derived price* (item 3) were put to the owner with this work and are not settled in it. +6. *Whether a quantity must be a multiple of the minimum order*, and *the rounding of a derived price*, were put to the owner with this work and settled afterwards: §11, decisions 11 and 12. 7. *The older tests of a race* — a payment reported twice at once, two refunds, a payment against a cancellation, nine in all — were looked at the same day, after the review. They call functions side by side rather than send requests, and such calls do overlap every time: twenty-five rounds of two payments went to the database in one and the same order, both readings before either write. So none had to be rewritten for that. They now run through `atTheSameMoment`, which fails unless the calls overlapped, so that it is seen on every run rather than taken on trust; and each of the nine, with everything that stops its second write taken away, was red eight times in eight. One thing that showed: a payment's write is stopped by several keys at once — the status condition, the event's two keys, the ledger row's id, the outbox row's id — so that removing the shared status condition turns no race between two payments red. The races between two different changes of an order — shipping against refunding, paying against cancelling — have no key in common, and the status condition is all that stops them: those go red without it. `atTheSameMoment` watches the order of each call's trips to the database it was handed, and is told how many of them are readings; it refuses calls that never write, which would not be a race. 8. *A request with no connecting address.* Whether a zone that uses Cloudflare's "Remove visitor IP headers" transform still hands a Worker that header is not stated in the documentation that was read. If it does not, every visitor of such a site shares one limit of five an hour. diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index 95cc5d9..77cf7db 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -490,10 +490,10 @@ try { `the product page offers no usable form for its 20 mm size: ${JSON.stringify(form)}`, ); expect( - / - + {% endif %} diff --git a/src/theme/partials/offer.liquid b/src/theme/partials/offer.liquid index 6f5be09..0ccb13c 100644 --- a/src/theme/partials/offer.liquid +++ b/src/theme/partials/offer.liquid @@ -25,8 +25,9 @@ {%- comment -%} The form that puts this size in the cart: a plain POST, no script. The - quantity starts at the minimum order, moves in steps of it and stops at - the most a cart line may hold — all three checked again by the server. + quantity starts at the minimum order and stops at the most a cart line + may hold — both checked again by the server — and may be any whole + number between: the minimum is a floor, not a pack size. A size that cannot be had is offered no form. One without a price is: it goes in the cart to be asked about, and the cart is sent as a request for a quote. The currency is the one the page shows; the currency switch @@ -36,7 +37,7 @@
- +
{% endif %} diff --git a/test/shop/cart-route.test.ts b/test/shop/cart-route.test.ts index b99806c..f14dbfb 100644 --- a/test/shop/cart-route.test.ts +++ b/test/shop/cart-route.test.ts @@ -172,6 +172,24 @@ describe('POST /_mallok/p/shop/cart/update', () => { expect(response.headers.get('set-cookie')).toBeNull(); }); + it('takes any whole number from the minimum order up, not only its multiples', async () => { + // The minimum order is a floor, not a pack size: with a minimum of ten, + // seventeen is an order like any other. + const added = await post({ variant: 'dn50', quantity: '17' }); + + expect(refusal(added).kind).toBeNull(); + expect(await lines()).toMatchObject([{ variant_id: 'dn50', quantity: 17 }]); + + const cookie = cartCookie(added); + const set = await post( + { action: 'set', variant: 'dn50', quantity: '23' }, + cookie, + ); + + expect(refusal(set).kind).toBeNull(); + expect(await lines()).toMatchObject([{ variant_id: 'dn50', quantity: 23 }]); + }); + it('adds to the quantity already in the cart', async () => { const first = await post({ variant: 'dn50', quantity: '10' }); const cookie = cartCookie(first); diff --git a/test/theme/cart.test.ts b/test/theme/cart.test.ts index 0240eae..a75bec5 100644 --- a/test/theme/cart.test.ts +++ b/test/theme/cart.test.ts @@ -307,13 +307,13 @@ describe('the cart', () => { action: `${SHOP}/cart/update`, method: 'post', fields: { variant: 'cs-10', currency: 'USD', quantity: '100' }, - // The minimum order is where the quantity starts and what it moves - // in steps of. + // The minimum order is where the quantity starts. From there any + // whole number may be asked for: it is a floor, not a pack size. quantity: { type: 'number', name: 'quantity', min: '100', - step: '100', + step: '1', // The most a cart line may hold. max: '500', value: '100', @@ -535,7 +535,7 @@ describe('the cart', () => { ]); }); - it('gives each line a form to set its quantity, in steps of its minimum order, and one to remove it', async () => { + it('gives each line a form to set its quantity, from its minimum order up, and one to remove it', async () => { const cookie = await add(screw.path, 'CS-16', '150'); const [line] = cartLines((await get(`${SHOP}/cart`, cookie)).html); @@ -548,7 +548,7 @@ describe('the cart', () => { type: 'number', name: 'quantity', min: '50', - step: '50', + step: '1', max: '500', value: '150', inputmode: 'numeric', @@ -724,7 +724,7 @@ describe('the cart', () => { // The form already asks for the new minimum. expect(line?.forms[0]?.quantity).toMatchObject({ min: '200', - step: '200', + step: '1', value: '100', }); // A sum nobody can pay is not stated.