From 720ab40e336d34582f7805b54f5adc3833f8ae20 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 11:53:15 +0100 Subject: [PATCH 1/6] fix(checkout): re-price the basket from the live catalogue and show Dr Green's delivery (BS-F01) The localStorage basket kept the add-to-cart price with no TTL, so checkout could show a price Dr Green no longer charges. Checkout now re-reads the tenant's catalogue on load and whenever the basket contents change, writes live prices back to the store, flags moved lines "Price updated" and removes lines no longer listed with a message. The summary shows subtotal, Dr Green's delivery charge (new GET /api/store/[slug]/checkout/quote, read off the customer's own server cart) or "Calculated by Dr Green", and the total; Place Order includes delivery. GET /dapp/carts ignores clientId and lists every client of the key with a cart, so getCart read another customer's cart via clients[0]. Both getCart and the quote now pick the customer's cart by id (pickClientCart). Copies the PRD to tasks/prd-drgreen-commission-flex.md with a status section. --- .../api/store/[slug]/checkout/quote/route.ts | 73 ++++++++ .../checkout/checkout-order-summary.tsx | 169 ++++++++++++++++++ .../app/store/[slug]/checkout/page.tsx | 124 +++---------- .../[slug]/checkout/use-checkout-pricing.ts | 107 +++++++++++ nextjs_space/lib/cart-store.ts | 4 + nextjs_space/lib/checkout/reprice-basket.ts | 97 ++++++++++ nextjs_space/lib/drgreen/delivery-quote.ts | 75 ++++++++ nextjs_space/lib/drgreen/drgreen-cart.ts | 6 +- .../unit/checkout-reprice-basket.test.ts | 117 ++++++++++++ .../tests/unit/delivery-quote.test.ts | 86 +++++++++ tasks/prd-drgreen-commission-flex.md | 109 +++++++++++ 11 files changed, 871 insertions(+), 96 deletions(-) create mode 100644 nextjs_space/app/api/store/[slug]/checkout/quote/route.ts create mode 100644 nextjs_space/app/store/[slug]/checkout/checkout-order-summary.tsx create mode 100644 nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts create mode 100644 nextjs_space/lib/checkout/reprice-basket.ts create mode 100644 nextjs_space/lib/drgreen/delivery-quote.ts create mode 100644 nextjs_space/tests/unit/checkout-reprice-basket.test.ts create mode 100644 nextjs_space/tests/unit/delivery-quote.test.ts create mode 100644 tasks/prd-drgreen-commission-flex.md diff --git a/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts b/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts new file mode 100644 index 00000000..08ff4139 --- /dev/null +++ b/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { withAuth } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; +import { fetchDeliveryQuote } from "@/lib/drgreen/delivery-quote"; +import { apiError } from "@/lib/api-error"; +import { parseSlug } from "@/lib/validation/parse-uuid"; +import { logger } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +const ROUTE = "GET /api/store/[slug]/checkout/quote"; +const NO_QUOTE = { deliveryCharge: null, currency: null } as const; + +/** + * GET /api/store/[slug]/checkout/quote — Dr Green's delivery charge for the + * signed-in customer (BS-F01), read off their Dr Green server cart. + * + * Best-effort by design: no quote is a normal answer (no server cart yet, no + * Dr Green client, Dr Green unreachable) and checkout then shows "calculated + * by Dr Green". It never invents a number — the charge is Dr Green's. + */ +export const GET = withAuth(async (_request, { user }, { slug }) => { + try { + parseSlug(slug); + + const email = user.email; + if (!email) return NextResponse.json(NO_QUOTE); + + // Resolve the local row by email, as the order-submit route does — the + // auth user's id is not guaranteed to be users.id. + const dbUser = await prisma.users.findFirst({ + where: { email }, + select: { drGreenClientId: true }, + }); + const clientId = dbUser?.drGreenClientId; + if (!clientId) return NextResponse.json(NO_QUOTE); + + const tenant = await getCurrentTenant(); + if (!tenant) { + return apiError(new Error("Store not found"), { + route: ROUTE, + status: 404, + safeMessage: "Store not found", + }); + } + + const config = await getTenantDrGreenConfig(tenant.id); + try { + const quote = await fetchDeliveryQuote({ + clientId, + email, + apiKey: config.apiKey, + secretKey: config.secretKey, + apiUrl: config.apiUrl, + }); + return NextResponse.json(quote ?? NO_QUOTE); + } catch (quoteError) { + logger.warn("[checkout-quote] Dr Green cart read failed", { + tenantId: tenant.id, + error: + quoteError instanceof Error ? quoteError.message : String(quoteError), + }); + return NextResponse.json(NO_QUOTE); + } + } catch (error) { + return apiError(error, { + route: ROUTE, + safeMessage: "Failed to get delivery quote", + }); + } +}); diff --git a/nextjs_space/app/store/[slug]/checkout/checkout-order-summary.tsx b/nextjs_space/app/store/[slug]/checkout/checkout-order-summary.tsx new file mode 100644 index 00000000..a48964d4 --- /dev/null +++ b/nextjs_space/app/store/[slug]/checkout/checkout-order-summary.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { AlertCircle } from "lucide-react"; +import type { CartItem } from "@/lib/cart-store"; +import type { PricingStatus } from "./use-checkout-pricing"; + +const TEXT = { + color: "hsl(var(--tenant-color-text))", + fontFamily: "var(--tenant-font-base, sans-serif)", +}; +const HEADING = { + color: "hsl(var(--tenant-color-heading))", + fontFamily: "var(--tenant-font-base, sans-serif)", +}; + +export const DELIVERY_UNQUOTED_LABEL = "Calculated by Dr Green"; + +function money(currency: string, value: number): string { + return `${currency}${value.toFixed(2)}`; +} + +function Row({ + label, + value, + strong, +}: { + label: string; + value: string; + strong?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +/** + * Checkout order summary (BS-F01): each line at the live catalogue price, + * subtotal, Dr Green's delivery charge and the total. Prices are shown as they + * are — no was/now, badge or percentage (Dr Green Flex PRD non-goals). + */ +export function CheckoutOrderSummary({ + items, + currency, + subtotal, + deliveryCharge, + pricingStatus, + updatedIds, + removedNames, +}: { + items: CartItem[]; + currency: string; + subtotal: number; + deliveryCharge: number | null; + pricingStatus: PricingStatus; + updatedIds: ReadonlySet; + removedNames: string[]; +}) { + const total = subtotal + (deliveryCharge ?? 0); + + return ( + + + + Order Summary + + + + {removedNames.length > 0 && ( +
+ + + {removedNames.join(", ")}{" "} + {removedNames.length === 1 ? "is" : "are"} no longer available + and {removedNames.length === 1 ? "was" : "were"} removed from + your basket. + +
+ )} + +
+ {items.map((item) => ( +
+ + {item.name} ({item.quantity}g) + {updatedIds.has(item.productId) && ( + + Price updated + + )} + + + {money(currency, item.price * item.quantity)} + +
+ ))} +
+ + + + + + + + {pricingStatus === "unconfirmed" && ( +

+ We could not confirm today's prices. Your order will be + charged at the store's current price. +

+ )} + +
+

+ Payment instructions will be sent after your order is placed. + Crypto and card options available. +

+
+
+
+ ); +} diff --git a/nextjs_space/app/store/[slug]/checkout/page.tsx b/nextjs_space/app/store/[slug]/checkout/page.tsx index 12644678..a6b3b15c 100644 --- a/nextjs_space/app/store/[slug]/checkout/page.tsx +++ b/nextjs_space/app/store/[slug]/checkout/page.tsx @@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { ArrowLeft, @@ -21,11 +20,15 @@ import Link from "next/link"; import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; import { getUserShippingAddress, type ShippingAddress } from "@/app/actions/get-user-shipping"; import { CheckoutOrderConfirmation, type OrderResult } from "./checkout-order-confirmation"; +import { CheckoutOrderSummary } from "./checkout-order-summary"; +import { useCheckoutPricing } from "./use-checkout-pricing"; export default function CheckoutPage({ params }: { params: { slug: string } }) { const router = useRouter(); const basePath = getTenantBasePath(params.slug); const { items, getTotalPrice, clearCart } = useCartStore(); + // BS-F01: lines at the live catalogue price + Dr Green's delivery quote. + const pricing = useCheckoutPricing(params.slug); const [mounted, setMounted] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -216,6 +219,13 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) { const currency = items[0]?.currency || "R"; const subtotal = getTotalPrice(); + // Place Order shows what will be charged: lines + Dr Green's delivery. With + // no quote it says delivery is added, rather than show a total that is short. + const orderTotal = subtotal + (pricing.deliveryCharge ?? 0); + const placeOrderAmount = + pricing.deliveryCharge === null + ? `${currency}${subtotal.toFixed(2)} + delivery` + : `${currency}${orderTotal.toFixed(2)}`; if (!mounted) { return ( @@ -275,7 +285,9 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) { fontFamily: "var(--tenant-font-base, sans-serif)", }} > - Add items before checking out. + {pricing.removedNames.length > 0 + ? `${pricing.removedNames.join(", ")} ${pricing.removedNames.length === 1 ? "is" : "are"} no longer available and ${pricing.removedNames.length === 1 ? "was" : "were"} removed from your basket.` + : "Add items before checking out."}

diff --git a/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts new file mode 100644 index 00000000..e68e7708 --- /dev/null +++ b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useCartStore } from "@/lib/cart-store"; +import { + basketSignature, + repriceBasket, + type LiveCatalogueProduct, +} from "@/lib/checkout/reprice-basket"; + +export type PricingStatus = "loading" | "live" | "unconfirmed"; + +export interface CheckoutPricing { + /** "live" once the basket matches the tenant's live catalogue. */ + status: PricingStatus; + /** productIds whose price was updated on this visit ("Price updated"). */ + updatedIds: ReadonlySet; + /** Names of lines removed because they are no longer listed. */ + removedNames: string[]; + /** Dr Green's delivery charge, or null when it has not quoted one. */ + deliveryCharge: number | null; +} + +/** + * BS-F01 — keep the checkout basket at the live catalogue price. + * + * Fetches the tenant's catalogue on load and whenever WHAT is in the basket + * changes (not when only a price is written back, see basketSignature), writes + * live prices into the basket store and drops lines no longer listed. The + * delivery quote is read once: it is per market, not per basket. + */ +export function useCheckoutPricing(slug: string): CheckoutPricing { + const items = useCartStore((s) => s.items); + const replaceItems = useCartStore((s) => s.replaceItems); + const signature = useMemo(() => basketSignature(items), [items]); + + const [status, setStatus] = useState("loading"); + const [updatedIds, setUpdatedIds] = useState>(new Set()); + const [removedNames, setRemovedNames] = useState([]); + const [deliveryCharge, setDeliveryCharge] = useState(null); + + useEffect(() => { + if (!signature) { + setStatus("live"); + return; + } + let cancelled = false; + setStatus("loading"); + + fetch(`/api/store/${slug}/products`, { cache: "no-store" }) + .then(async (res) => { + const body = await res.json().catch(() => null); + if (!res.ok || !body?.success || !Array.isArray(body.data)) { + throw new Error("catalogue unavailable"); + } + return body.data as LiveCatalogueProduct[]; + }) + .then((catalogue) => { + if (cancelled) return; + // Read the basket now, not when the effect started, so a change made + // while the request was in flight is never overwritten. + const result = repriceBasket(useCartStore.getState().items, catalogue); + if (result.changed) replaceItems(result.items); + if (result.priceChanged.length > 0) { + setUpdatedIds( + (prev) => new Set([...Array.from(prev), ...result.priceChanged]), + ); + } + if (result.removed.length > 0) { + setRemovedNames((prev) => [ + ...prev, + ...result.removed.map((r) => r.name), + ]); + } + setStatus("live"); + }) + .catch(() => { + // The order is still priced by Dr Green server-side; say we could not + // confirm the price here rather than block the customer. + if (!cancelled) setStatus("unconfirmed"); + }); + + return () => { + cancelled = true; + }; + }, [slug, signature, replaceItems]); + + useEffect(() => { + let cancelled = false; + fetch(`/api/store/${slug}/checkout/quote`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + const value = body?.deliveryCharge; + if (!cancelled && typeof value === "number" && Number.isFinite(value)) { + setDeliveryCharge(value); + } + }) + .catch(() => { + /* no quote — checkout shows "calculated by Dr Green" */ + }); + return () => { + cancelled = true; + }; + }, [slug]); + + return { status, updatedIds, removedNames, deliveryCharge }; +} diff --git a/nextjs_space/lib/cart-store.ts b/nextjs_space/lib/cart-store.ts index 7fec83ea..108a72c8 100644 --- a/nextjs_space/lib/cart-store.ts +++ b/nextjs_space/lib/cart-store.ts @@ -21,6 +21,8 @@ interface CartStore { removeItem: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; clearCart: () => void; + /** Replace the basket wholesale — checkout's live re-price (BS-F01). */ + replaceItems: (items: CartItem[]) => void; getTotalItems: () => number; getTotalPrice: () => number; } @@ -72,6 +74,8 @@ export const useCartStore = create()( clearCart: () => set({ items: [] }), + replaceItems: (items) => set({ items: [...items] }), + getTotalItems: () => { return get().items.length; }, diff --git a/nextjs_space/lib/checkout/reprice-basket.ts b/nextjs_space/lib/checkout/reprice-basket.ts new file mode 100644 index 00000000..4e8c81c1 --- /dev/null +++ b/nextjs_space/lib/checkout/reprice-basket.ts @@ -0,0 +1,97 @@ +/** + * Re-price the browser basket against the live catalogue (BS-F01). + * + * The basket lives in localStorage with the price captured at add-to-cart and + * no TTL, so it can show a price Dr Green no longer charges — and with Dr + * Green Commission Flex every KEY holder can move their storefront's price at + * any time. Checkout therefore re-reads the tenant's own catalogue and shows + * each line at the live price. Pure and client-safe: no I/O, no mutation of + * the inputs. + */ +import type { CartItem } from "@/lib/cart-store"; + +/** One product from GET /api/store/[slug]/products, as checkout needs it. */ +export interface LiveCatalogueProduct { + id: string; + price?: number; + retailPrice?: number; + currency?: string; + isAvailable?: boolean; + in_stock?: boolean; +} + +export interface RepriceResult { + /** The basket with live prices; unlisted/unavailable lines removed. */ + items: CartItem[]; + /** productIds whose stored price differed from the live price. */ + priceChanged: string[]; + /** Lines dropped because the product is no longer listed or available. */ + removed: CartItem[]; + /** True when anything differs from the input basket. */ + changed: boolean; +} + +/** Prices are money: anything under half a cent is the same price. */ +const PRICE_EPSILON = 0.005; + +/** + * The per-gram price the storefront shows for a product. Mirrors the product + * card and the detail page (`product.price || product.retailPrice || 0`), so + * checkout and the product pages can never disagree. + */ +export function livePriceOf(product: LiveCatalogueProduct): number { + return product.price || product.retailPrice || 0; +} + +/** Orderable now: the order-submit route drops anything else server-side. */ +function isOrderable(product: LiveCatalogueProduct): boolean { + return product.isAvailable !== false && product.in_stock !== false; +} + +export function repriceBasket( + items: readonly CartItem[], + catalogue: readonly LiveCatalogueProduct[], +): RepriceResult { + const byId = new Map(catalogue.map((p) => [p.id, p])); + const kept: CartItem[] = []; + const removed: CartItem[] = []; + const priceChanged: string[] = []; + + for (const item of items) { + const product = byId.get(item.productId); + if (!product || !isOrderable(product)) { + removed.push(item); + continue; + } + const live = livePriceOf(product); + const currency = product.currency || item.currency; + const priceMoved = Math.abs(live - item.price) >= PRICE_EPSILON; + if (priceMoved) priceChanged.push(item.productId); + kept.push( + priceMoved || currency !== item.currency + ? { ...item, price: live, currency } + : item, + ); + } + + const changed = + removed.length > 0 || kept.some((item, i) => item !== items[i]); + return { items: kept, priceChanged, removed, changed }; +} + +/** Sum of price × grams — the same arithmetic as the cart store. */ +export function basketSubtotal(items: readonly CartItem[]): number { + return items.reduce((sum, item) => sum + item.price * item.quantity, 0); +} + +/** + * A stable signature of WHAT is in the basket (not what it costs), so the + * checkout re-prices when a line is added, removed or re-weighed, but writing + * the live price back into the store does not trigger another fetch. + */ +export function basketSignature(items: readonly CartItem[]): string { + return items + .map((i) => `${i.productId}:${i.quantity}`) + .sort() + .join("|"); +} diff --git a/nextjs_space/lib/drgreen/delivery-quote.ts b/nextjs_space/lib/drgreen/delivery-quote.ts new file mode 100644 index 00000000..9928f4ef --- /dev/null +++ b/nextjs_space/lib/drgreen/delivery-quote.ts @@ -0,0 +1,75 @@ +/** + * Delivery quote for checkout (BS-F01) — read off Dr Green's server cart. + * + * Dr Green exposes the charge it will bill on top of the order total as + * `localPrices.deliveryCharge` on each cart in GET /dapp/carts (added with + * dr-green-backend US-011 / defect H). The catalogue does not carry it and + * there is no per-market delivery endpoint, so the server cart is the only + * place a storefront can read it before the order exists. When the customer + * has no server cart (the common case: BudStacks keeps the basket in the + * browser and only pushes it to Dr Green at submit), there is no quote and + * checkout says delivery is calculated by Dr Green. + * + * GET /dapp/carts lists EVERY client of the calling key that has a non-empty + * cart, newest first, ten per page. It does not filter by `clientId` + * (GetCartsDto has only `search`; the whitelist strips anything else), so the + * customer's own cart must be picked out by id — reading `clients[0]` returns + * whichever customer of the store touched a cart last. + */ +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; + +export interface DeliveryQuote { + /** In the market's own currency, billed on top of the line items. */ + deliveryCharge: number; + /** ISO code Dr Green reported for the cart, when it reported one. */ + currency: string | null; +} + +/** The given client's cart out of a GET /dapp/carts response, or null. */ +export function pickClientCart(response: unknown, clientId: string): any | null { + const body = response as { data?: { clients?: unknown }; clients?: unknown } | null; + const clients = body?.data?.clients ?? body?.clients; + if (!Array.isArray(clients) || !clientId) return null; + const mine = clients.find((c: any) => c?.id === clientId); + const cart = mine?.clientCart?.[0]; + return cart ?? null; +} + +/** The delivery quote on a Dr Green cart, or null when it carries none. */ +export function deliveryQuoteFromCart(cart: unknown): DeliveryQuote | null { + const localPrices = (cart as { localPrices?: Record } | null) + ?.localPrices; + const raw = localPrices?.deliveryCharge; + const value = typeof raw === "string" ? Number(raw) : raw; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + const currency = localPrices?.currency; + return { + deliveryCharge: value, + currency: typeof currency === "string" && currency ? currency : null, + }; +} + +/** + * Fetch the customer's delivery quote. `search` narrows Dr Green's list to the + * customer's email (it matches name or email, case-insensitive) so their cart + * is on the first page; the id match then makes the pick exact. + */ +export async function fetchDeliveryQuote(params: { + clientId: string; + email: string; + apiKey: string; + secretKey: string; + apiUrl?: string; +}): Promise { + const { clientId, email, apiKey, secretKey, apiUrl } = params; + const response = await callDrGreenAPI("/dapp/carts", { + method: "GET", + apiKey, + secretKey, + baseUrl: apiUrl, + queryParams: { search: email }, + }); + return deliveryQuoteFromCart(pickClientCart(response, clientId)); +} diff --git a/nextjs_space/lib/drgreen/drgreen-cart.ts b/nextjs_space/lib/drgreen/drgreen-cart.ts index 717600ce..11253ca2 100644 --- a/nextjs_space/lib/drgreen/drgreen-cart.ts +++ b/nextjs_space/lib/drgreen/drgreen-cart.ts @@ -8,6 +8,7 @@ import { prisma } from "@/lib/db"; import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; import { getClientCartId } from "@/lib/drgreen/drgreen-client-cart"; +import { pickClientCart } from "@/lib/drgreen/delivery-quote"; export interface CartItem { strainId: string; @@ -183,7 +184,10 @@ export async function getCart(params: { queryParams: { clientId }, }); - const cartData = (response as any).data?.clients?.[0]?.clientCart?.[0]; + // The list is every client of this key with a non-empty cart (Dr Green + // ignores the clientId filter), so take THIS customer's cart by id — + // `clients[0]` was whichever customer of the store touched a cart last. + const cartData = pickClientCart(response, clientId); if (cartData) { const items = cartData.cartItems.map((item: any) => ({ diff --git a/nextjs_space/tests/unit/checkout-reprice-basket.test.ts b/nextjs_space/tests/unit/checkout-reprice-basket.test.ts new file mode 100644 index 00000000..614f1cf6 --- /dev/null +++ b/nextjs_space/tests/unit/checkout-reprice-basket.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { CartItem } from "@/lib/cart-store"; +import { + basketSignature, + basketSubtotal, + livePriceOf, + repriceBasket, +} from "@/lib/checkout/reprice-basket"; + +/** + * BS-F01 (Dr Green Commission Flex readiness): the basket is in localStorage + * with the price captured at add-to-cart, so checkout re-prices every line + * from the tenant's live catalogue before the customer submits. + */ +function line(over: Partial = {}): CartItem { + return { + id: "s-1", + productId: "s-1", + name: "Strain One", + price: 165, + quantity: 5, + currency: "R", + ...over, + }; +} + +const live = (over: Record = {}) => ({ + id: "s-1", + price: 165, + currency: "R", + isAvailable: true, + in_stock: true, + ...over, +}); + +describe("repriceBasket", () => { + it("leaves a basket that already matches the catalogue untouched", () => { + const items = [line()]; + const result = repriceBasket(items, [live()]); + expect(result.changed).toBe(false); + expect(result.priceChanged).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.items[0]).toBe(items[0]); // same object, no needless write + }); + + it("moves a stale line to the live price and reports it once", () => { + const items = [line({ price: 165 })]; + const result = repriceBasket(items, [live({ price: 132 })]); + expect(result.changed).toBe(true); + expect(result.priceChanged).toEqual(["s-1"]); + expect(result.items[0]).toMatchObject({ productId: "s-1", price: 132, quantity: 5 }); + // Re-pricing the updated basket finds nothing more to change. + const again = repriceBasket(result.items, [live({ price: 132 })]); + expect(again.changed).toBe(false); + expect(again.priceChanged).toEqual([]); + }); + + it("never mutates the stored basket", () => { + const items = [line({ price: 165 })]; + const snapshot = JSON.parse(JSON.stringify(items)); + repriceBasket(items, [live({ price: 99 })]); + expect(items).toEqual(snapshot); + }); + + it("removes a product that is no longer listed", () => { + const keep = line({ id: "s-2", productId: "s-2", name: "Kept" }); + const gone = line({ id: "s-9", productId: "s-9", name: "Gone" }); + const result = repriceBasket([gone, keep], [live({ id: "s-2" })]); + expect(result.items.map((i) => i.productId)).toEqual(["s-2"]); + expect(result.removed.map((i) => i.name)).toEqual(["Gone"]); + expect(result.changed).toBe(true); + }); + + it("removes a listed product that can no longer be ordered", () => { + const result = repriceBasket( + [line(), line({ id: "s-2", productId: "s-2" })], + [live(), live({ id: "s-2", isAvailable: false })], + ); + expect(result.items.map((i) => i.productId)).toEqual(["s-1"]); + expect(result.removed.map((i) => i.productId)).toEqual(["s-2"]); + }); + + it("ignores sub-cent float noise", () => { + const result = repriceBasket([line({ price: 165 })], [live({ price: 165.001 })]); + expect(result.changed).toBe(false); + }); + + it("takes the live currency with the live price", () => { + const result = repriceBasket([line({ currency: "€" })], [live({ currency: "R" })]); + expect(result.items[0].currency).toBe("R"); + expect(result.priceChanged).toEqual([]); + expect(result.changed).toBe(true); + }); +}); + +describe("livePriceOf", () => { + it("prefers the normalised price, then retailPrice, then 0 — like the product pages", () => { + expect(livePriceOf({ id: "a", price: 132, retailPrice: 10 })).toBe(132); + expect(livePriceOf({ id: "a", retailPrice: 10 })).toBe(10); + expect(livePriceOf({ id: "a" })).toBe(0); + }); +}); + +describe("basketSubtotal / basketSignature", () => { + it("sums price × grams", () => { + expect( + basketSubtotal([line({ price: 132, quantity: 5 }), line({ productId: "s-2", price: 100, quantity: 2 })]), + ).toBe(860); + }); + + it("changes with contents but not with price, so writing a live price back does not refetch", () => { + const a = basketSignature([line({ price: 165 })]); + expect(basketSignature([line({ price: 132 })])).toBe(a); + expect(basketSignature([line({ quantity: 10 })])).not.toBe(a); + expect(basketSignature([])).toBe(""); + }); +}); diff --git a/nextjs_space/tests/unit/delivery-quote.test.ts b/nextjs_space/tests/unit/delivery-quote.test.ts new file mode 100644 index 00000000..ebe1be28 --- /dev/null +++ b/nextjs_space/tests/unit/delivery-quote.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: vi.fn() })); + +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { + deliveryQuoteFromCart, + fetchDeliveryQuote, + pickClientCart, +} from "@/lib/drgreen/delivery-quote"; + +/** + * BS-F01: checkout reads Dr Green's delivery charge off the customer's OWN + * server cart. GET /dapp/carts lists every client of the key with a cart and + * ignores clientId, so the pick must be by id, never `clients[0]`. + */ +const cart = (deliveryCharge: unknown, currency = "ZAR") => ({ + id: "cart-1", + cartItems: [], + localPrices: { currency, totalAmount: 825, deliveryCharge, grandTotal: 935 }, +}); + +const listResponse = { + success: true, + data: { + clients: [ + { id: "someone-else", clientCart: [cart(60, "EUR")] }, + { id: "client-1", clientCart: [cart(110)] }, + ], + }, +}; + +describe("pickClientCart", () => { + it("returns this customer's cart, not the first client in the list", () => { + expect(pickClientCart(listResponse, "client-1")?.localPrices.deliveryCharge).toBe(110); + }); + + it("returns null when the customer has no cart in the list", () => { + expect(pickClientCart(listResponse, "client-404")).toBeNull(); + expect(pickClientCart({ data: { clients: [] } }, "client-1")).toBeNull(); + expect(pickClientCart(null, "client-1")).toBeNull(); + expect(pickClientCart(listResponse, "")).toBeNull(); + }); +}); + +describe("deliveryQuoteFromCart", () => { + it("reads localPrices.deliveryCharge and currency", () => { + expect(deliveryQuoteFromCart(cart(110))).toEqual({ deliveryCharge: 110, currency: "ZAR" }); + }); + + it("accepts free delivery and numeric strings", () => { + expect(deliveryQuoteFromCart(cart(0))?.deliveryCharge).toBe(0); + expect(deliveryQuoteFromCart(cart("110.5"))?.deliveryCharge).toBe(110.5); + }); + + it.each([[undefined], [null], ["free"], [-1]])("never invents a charge from %s", (value) => { + expect(deliveryQuoteFromCart(cart(value))).toBeNull(); + }); + + it("returns null when there is no cart", () => { + expect(deliveryQuoteFromCart(null)).toBeNull(); + }); +}); + +describe("fetchDeliveryQuote", () => { + beforeEach(() => vi.clearAllMocks()); + + it("narrows the signed GET by the customer's email and picks by id", async () => { + (callDrGreenAPI as any).mockResolvedValue(listResponse); + const quote = await fetchDeliveryQuote({ + clientId: "client-1", + email: "ann@example.com", + apiKey: "k", + secretKey: "s", + apiUrl: "https://stage/api/v1", + }); + expect(quote).toEqual({ deliveryCharge: 110, currency: "ZAR" }); + const [endpoint, opts] = (callDrGreenAPI as any).mock.calls[0]; + expect(endpoint).toBe("/dapp/carts"); + expect(opts).toMatchObject({ + method: "GET", + baseUrl: "https://stage/api/v1", + queryParams: { search: "ann@example.com" }, + }); + }); +}); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md new file mode 100644 index 00000000..80a74bc9 --- /dev/null +++ b/tasks/prd-drgreen-commission-flex.md @@ -0,0 +1,109 @@ +# PRD — BudStacks: Dr Green Commission Flex readiness + +| | | +|---|---| +| **Version** | v2 — 2026-10-05 (built; v1 draft the same day) | +| **Owner** | Gerard Kavanagh (CTO; owns and releases BudStacks) | +| **Surface** | `budstack-saas` (Next.js multi-tenant storefront, `nextjs_space/`) | +| **Related** | Dr Green `dr-green-backend/docs/prd/commission-flex.prd.md` and `order-line-price-snapshot.prd.md`; design https://claude.ai/artifact/6X9giM6SSdadKH9oH4d5xk (section B "What changes, by repo") | +| **Depends on** | Nothing. BS-F01..F03 fix defects that exist today and must be live **before** any BudStacks tenant's KEY uses Flex. BS-F04 needs Dr Green Flex Phase A on production. | +| **Compatibility** | BudStacks is one deployment. Dr Green Flex changes no field names: the storefront's price arrives in `retailPrice` and `strainLocations[].retailPrice` as today, already reduced for the tenant's KEY. | +| **Estimate** | 2–3 engineer-days (BS-F01..F03) + 0.5 d (BS-F04) | +| **Branch** | `feat/drgreen-flex-readiness` (from `origin/main` @ `79e0b098`), one commit per story | + +--- + +## 0. Status (2026-10-05) + +BS-F01 is built on the branch above with unit tests. Not yet done, and why: + +- **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`. +- **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. + +### Corrections found in code while building (v1 → v2) + +1. **GET `/dapp/carts` ignores `clientId`.** `GetCartsDto` has only `search` (plus pagination), the whitelist strips the rest, and the list is every client of the key with a non-empty cart, newest first, ten per page (`dr-green-backend src/carts/carts.service.ts getCartList`). `getCart` read `data.clients[0]` — whichever customer of the store touched a cart last — and wrote that cart into the signed-in user's `drgreen_carts` mirror. Fixed with `pickClientCart(response, clientId)` (`lib/drgreen/delivery-quote.ts`), used by `getCart` and the new quote. +2. **There was no "existing checkout quote path".** Nothing in BudStacks read `localPrices.deliveryCharge`, and the catalogue (`/dapp/strains`) does not carry the delivery charge. Added `GET /api/store/[slug]/checkout/quote` (signed `GET /dapp/carts?search=`, picked by client id). **Expect "Calculated by Dr Green" on most checkouts:** BudStacks keeps the basket in the browser and only pushes it to Dr Green at submit, and Dr Green empties the server cart when an order is created, so the customer usually has no server cart to quote from. Showing the real charge every time needs either a Dr Green delivery-quote endpoint (e.g. `deliveryCharge` on the `/dapp/strains` location select) or pushing the basket to Dr Green's cart at checkout load — see §8. + +## 1. Introduction / Overview + +Dr Green will let a KEY holder lower the price on their own storefront by keeping less commission. The catalogue a BudStacks tenant fetches with its own key will then carry that tenant's price. Three things in BudStacks assume one price for everyone and have to change first; they are wrong today as well, just less visibly. + +Verified in code 2026-10-02 (`origin/main` 79e0b098): + +- The basket lives in the browser (`lib/cart-store.ts`, zustand persisted to localStorage `budstack-cart`, no TTL) with `price` captured at add-to-cart. Cart, dropdown and checkout totals are `price × quantity` client-side (`cart-store.ts:79-85`); the "Place Order" button total excludes delivery. +- Order submit sends the browser's prices (`app/store/[slug]/checkout/page.tsx:157-176`), the route reconciles availability but does not re-price (`app/api/store/[slug]/orders/submit/route.ts:159-183`), and the local order row is priced from the client-sent values (`lib/drgreen/drgreen-orders.ts:144, 191-234`). `syncOneOrder` never syncs totals (`lib/orders/storefront-orders.ts:96-131`). So order history, confirmation, analytics revenue and packing slips show storefront-computed totals, not what Dr Green charged. +- `fetchProduct`'s 60-second in-memory cache is keyed `${country}:${config.apiUrl}` (`lib/drgreen/doctor-green-api.ts:381-398`), with no tenant or key in the key. With per-KEY prices, one tenant's product page metadata and JSON-LD `Offer.price` could show another tenant's price for up to 60 s. +- `normalizeProduct` already prefers `strainLocations[0].retailPrice` + `location.currency` (`doctor-green-api.ts:275-297`), so the list and product pages pick up a changed price on the next request (`app/api/store/[slug]/products/route.ts` is `force-dynamic`). +- A dormant `-X% OFF` badge keys on `product.discount` (`app/store/[slug]/products/[id]/product-detail-client.tsx:222-232`); Dr Green never sends `discount`. + +## 2. Goals + +- The amount a customer sees at checkout is the amount Dr Green will charge, line by line, plus Dr Green's delivery charge. +- Local order rows, history, confirmation, analytics and packing slips carry Dr Green's totals. +- No tenant can ever be served another tenant's price from a cache. +- Nothing on a BudStacks storefront presents a price as a discount. + +## 3. User stories + +### BS-F01: Re-price the basket at checkout — ✅ built +**Description:** As a customer, I want the checkout total to be the price I will be charged, even if a price changed since I added the item. + +**Acceptance Criteria:** +- [x] The checkout page fetches the live catalogue (`/api/store/[slug]/products`) on load and whenever the basket changes, and shows each line at the live price; a line whose price differs from the stored basket price shows "Price updated" once and the basket store is updated to the live value. — `lib/checkout/reprice-basket.ts` (pure merge), `app/store/[slug]/checkout/use-checkout-pricing.ts` (fetch keyed on `basketSignature`, so writing the live price back does not refetch), new `replaceItems` on `lib/cart-store.ts`. Place Order is disabled while prices are being checked. +- [x] A product no longer listed is removed from the basket with a message, before submit (today it is dropped silently server-side). Also removes a listed product that is no longer orderable (`isAvailable`/`in_stock` false), which the submit route drops too. +- [x] The order summary shows subtotal, Dr Green's delivery charge (from the server cart `localPrices.deliveryCharge` ~~via the existing checkout quote path~~ via a new `GET /api/store/[slug]/checkout/quote` — there was no existing quote path, see §0 correction 2 — or "calculated by Dr Green" when unavailable) and the total; the Place Order button shows the total including delivery (or "+ delivery" when Dr Green has not quoted it). Summary extracted to `checkout-order-summary.tsx` (page was 777 lines). +- [x] Unit test for the re-price merge (`tests/unit/checkout-reprice-basket.test.ts`, `tests/unit/delivery-quote.test.ts`). +- [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* +- [ ] Verify in browser on a tenant after deploy (no staging) with a test account. + +### BS-F02: Local order row priced from Dr Green's response +**Description:** As a tenant admin and a customer, I want order history to show what was charged. + +**Acceptance Criteria:** +- [ ] `submitOrder` (`lib/drgreen/drgreen-orders.ts`) prices `order_items.price` and `orders.subtotal/total` from Dr Green's order response (`totalAmount`, `deliveryCharge`, and per-line `localPrice.productAmount` from `GET /dapp/orders/:id` when the create response lacks lines), never from the request body. The client-sent `strain.retailPrice` is ignored for pricing (kept only for the product name/image fallback). +- [ ] `syncOneOrder` also mirrors `totalAmount` and `deliveryCharge` into `subtotal`, `shippingCost`, `total` when they differ (Dr Green is the source of truth). +- [ ] Analytics revenue (`app/api/tenant-admin/analytics/route.ts`) needs no change once rows are correct; confirm with a test fixture. +- [ ] Unit tests: response-priced row; mismatch between client price and Dr Green price → Dr Green wins and the difference is logged at warn. +- [ ] Typecheck/lint passes. + +### BS-F03: Tenant-scoped product cache +**Acceptance Criteria:** +- [ ] `fetchProduct` cache key includes the tenant's API key id or tenant id: `${tenantKey}:${country}:${config.apiUrl}`; `invalidateProductCache()` keeps clearing everything. +- [ ] Platform-key fallback tenants (`lib/tenant/tenant-config.ts:85-90`) share the platform key's cache entry, which is correct (they get the platform key's price). +- [ ] Unit test: two configs, same country, different keys → independent entries. +- [ ] Typecheck/lint passes. + +### BS-F04: No discount presentation +**Acceptance Criteria:** +- [ ] Remove the `-X% OFF` badge in `product-detail-client.tsx` (or hard-disable it); add a code comment pointing at the Dr Green Flex PRD non-goals. +- [ ] Grep the storefront for strike-through price styling (`line-through` near a price) and remove any found. +- [ ] Verify in browser; typecheck/lint passes. + +## 4. Functional requirements + +- FR-1: Checkout totals are computed from the live catalogue and Dr Green's delivery charge. +- FR-2: Local order rows are written and synced from Dr Green's totals. +- FR-3: Product cache entries are tenant-scoped. +- FR-4: No was/now, badge or percentage-off appears on any storefront price. + +## 5. Non-goals + +- Any tenant-admin UI for Flex (the holder sets it in the Dr Green dApp). +- Multi-currency display changes; `lib/exchange-rates.ts` stays as the FX fallback. +- Removing the localStorage basket. + +## 6. Technical considerations + +- CI runs only on a PR to `main` and a PR merges instantly, so run `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test` before opening the PR (house rule: nothing runs on the workstation). +- The orders submit schema is `.strict()` at the top level with `cartItems[].passthrough()`; BS-F02 can drop the `strain` object from what the client sends once the server no longer reads prices from it. +- `prisma/schema.prisma:573`: migrations are hand-run SQL; none is needed here. + +## 7. Success metrics + +- For every new order, `orders.total` equals Dr Green's `totalAmount + deliveryCharge` for the same order. +- Zero product-page `Offer.price` values that differ from the tenant's own catalogue price. + +## 8. Open questions (added while building) + +- **Delivery quote source (BS-F01).** Dr Green has no per-market delivery quote a storefront can read before an order exists; the server cart only has one while it holds items. Options: (a) Dr Green adds `deliveryCharge` to the `location` select of `/dapp/strains` (one line, no shape change for other consumers); (b) BudStacks pushes the basket to `POST /dapp/carts` on checkout load (side effects on Dr Green's cart; the submit path already re-pushes). (a) is the clean one. Until then checkout shows "Calculated by Dr Green" and the Place Order button says "+ delivery". From 8d256cc7e380b0839de683ba60fd522c16432aae Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 11:57:28 +0100 Subject: [PATCH 2/6] fix(orders): price local order rows from Dr Green's order, not the browser (BS-F02) submitOrder wrote orders.subtotal/total and order_items.price from the prices the browser sent, so history, confirmation, analytics and packing slips showed storefront-computed totals. It now takes subtotal from Dr Green's stored totalAmount and prices each line from GET /dapp/orders/:id (localPrice.productAmount is the line total, so price = amount / grams). If that read fails the line falls back to the server-fetched live catalogue; the browser price is only compared and logged at warn. syncOneOrder mirrors Dr Green's totals when they differ, reading the local localPrice.totalAmount and the stored deliveryCharge. orderDetails.totalAmount is not used: Dr Green's reader overwrites it with the USD base sum. --- .../api/store/[slug]/orders/submit/route.ts | 10 + nextjs_space/lib/drgreen/drgreen-orders.ts | 57 +++- nextjs_space/lib/drgreen/order-pricing.ts | 274 ++++++++++++++++++ nextjs_space/lib/orders/storefront-orders.ts | 54 +++- nextjs_space/tests/unit/order-pricing.test.ts | 164 +++++++++++ .../storefront-orders-sync-totals.test.ts | 93 ++++++ .../tests/unit/submit-order-pricing.test.ts | 164 +++++++++++ tasks/prd-drgreen-commission-flex.md | 16 +- 8 files changed, 803 insertions(+), 29 deletions(-) create mode 100644 nextjs_space/lib/drgreen/order-pricing.ts create mode 100644 nextjs_space/tests/unit/order-pricing.test.ts create mode 100644 nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts create mode 100644 nextjs_space/tests/unit/submit-order-pricing.test.ts diff --git a/nextjs_space/app/api/store/[slug]/orders/submit/route.ts b/nextjs_space/app/api/store/[slug]/orders/submit/route.ts index 0fcaed0b..98e2b9bf 100644 --- a/nextjs_space/app/api/store/[slug]/orders/submit/route.ts +++ b/nextjs_space/app/api/store/[slug]/orders/submit/route.ts @@ -162,9 +162,18 @@ export const POST = withAuth(async (request, { user }, { slug }) => { // 400 the WHOLE order. Validate against the live catalog and drop anything // no longer available, so a stale item can never fail a customer's order. let itemsToOrder = cartItems; + // BS-F02: the same live catalogue is the server-side price fallback for a + // line Dr Green's order response does not price. The browser's prices are + // never used to price the order. + let catalogueUnitPrices: Record | undefined; if (cartItems && cartItems.length > 0) { const country = tenant.countryCode || "ZA"; const liveProducts = await fetchProducts(country, drGreenConfig); + catalogueUnitPrices = Object.fromEntries( + liveProducts + .map((p) => [p.id, p.price || p.retailPrice || 0] as const) + .filter(([, price]) => price > 0), // 0 = "price unavailable" + ); const availableIds = new Set( liveProducts.filter((p) => p.isAvailable !== false).map((p) => p.id), ); @@ -205,6 +214,7 @@ export const POST = withAuth(async (request, { user }, { slug }) => { secretKey: drGreenConfig.secretKey, apiUrl: drGreenConfig.apiUrl, clientCartItems: itemsToOrder, + catalogueUnitPrices, paymentFlow: directPayEnabled ? "DIRECT" : "LINK", }); diff --git a/nextjs_space/lib/drgreen/drgreen-orders.ts b/nextjs_space/lib/drgreen/drgreen-orders.ts index 34a9fe0e..c7c8591b 100644 --- a/nextjs_space/lib/drgreen/drgreen-orders.ts +++ b/nextjs_space/lib/drgreen/drgreen-orders.ts @@ -13,6 +13,7 @@ import { prisma } from "@/lib/db"; import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; import { getClientCartId } from "@/lib/drgreen/drgreen-client-cart"; import { deliveryChargeFromOrder } from "@/lib/drgreen/delivery"; +import { moneyOrNull, resolveOrderPricing } from "@/lib/drgreen/order-pricing"; import { logger } from "@/lib/logger"; export interface OrderSubmissionData { @@ -47,12 +48,18 @@ export async function submitOrder(params: { secretKey: string; apiUrl?: string; clientCartItems?: any[]; + /** + * Per-gram prices from the tenant's live catalogue, fetched server-side by + * the submit route. Fallback only, for a line Dr Green's order response + * does not price (BS-F02). Never the browser's prices. + */ + catalogueUnitPrices?: Record; // "DIRECT" for pay-at-checkout storefronts so Dr Green defers the admin // "order placed" email until payment succeeds; omitted/"LINK" keeps the // legacy behaviour (order placed → orders team emailed now). paymentFlow?: "DIRECT" | "LINK"; }): Promise { - const { userId, tenantId, shippingInfo, apiKey, secretKey, apiUrl, clientCartItems, paymentFlow } = params; + const { userId, tenantId, shippingInfo, apiKey, secretKey, apiUrl, clientCartItems, catalogueUnitPrices, paymentFlow } = params; const requestId = `ord_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const log = (step: string, data?: any) => { logger.info(`[${requestId}] ${step}`, data !== undefined ? { data } : undefined); @@ -139,13 +146,20 @@ export async function submitOrder(params: { // discontinued or recreated) is done upstream in the order-submit route, which // has the tenant's MARKET country. Repeating it here would query the catalog // with the customer's SHIPPING country (e.g. "Portugal") and wrongly 400. + // + // BS-F02: the price the browser sent is NOT used to price anything. The + // local row is priced from Dr Green's order response after the order is + // created; `clientPrice` is kept only to log a shown-vs-charged difference. + // `strain.name` remains the product-name fallback. const cartItems = (cart.items as any[]).map(item => ({ - ...item, - price: item.price || item.strain?.retailPrice || item.retailPrice || 0, - name: item.name || item.strain?.name || 'Unknown Product', + strainId: item.strainId as string, + quantity: item.quantity as number, + name: (item.name || item.strain?.name || 'Unknown Product') as string, + clientPrice: + moneyOrNull(item.price) ?? moneyOrNull(item.strain?.retailPrice) ?? moneyOrNull(item.retailPrice), })); log('CART_ITEMS', cartItems.map(i => ({ - strainId: i.strainId, name: i.name, qty: i.quantity, price: i.price, + strainId: i.strainId, name: i.name, qty: i.quantity, clientPrice: i.clientPrice, }))); // ========== Step 0: Get clientCartId ========== @@ -188,7 +202,20 @@ export async function submitOrder(params: { } // ========== Save order locally ========== - const subtotal = cartItems.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0); + // BS-F02: Dr Green's totals, never the request body's. subtotal is the + // line-items total Dr Green stored on the order (its own currency); each + // line is priced from Dr Green's order lines (GET /dapp/orders/:id, since + // the create response carries none). See lib/drgreen/order-pricing.ts. + const pricing = await resolveOrderPricing({ + orderData, + items: cartItems, + catalogueUnitPrices, + apiKey, + secretKey, + apiUrl, + requestId, + }); + const subtotal = pricing.subtotal; // Dr Green owns the delivery charge and bills it on top of the order total, // so take it from the order it just created rather than inventing one. A // hardcoded 5.0 here meant the customer saw R5 while their card was charged @@ -197,7 +224,13 @@ export async function submitOrder(params: { const shippingCost = deliveryChargeFromOrder(orderData); const total = subtotal + shippingCost; - log('DR_GREEN_ORDER_SUCCESS', { drGreenOrderId: orderData.id }); + log('DR_GREEN_ORDER_SUCCESS', { + drGreenOrderId: orderData.id, + subtotal, + shippingCost, + total, + lineSources: pricing.lines.map(l => l.source), + }); // Persist the order WITHOUT an interactive transaction. The irreversible step // — the Dr Green order — has already succeeded above, so the local save must @@ -221,12 +254,12 @@ export async function submitOrder(params: { orderNumber: `ORD-${Date.now()}`, updatedAt: new Date(), order_items: { - create: cartItems.map((item) => ({ + create: pricing.lines.map((line) => ({ id: crypto.randomUUID(), - productId: item.strainId, - productName: item.name, - quantity: item.quantity, - price: item.price, + productId: line.strainId, + productName: line.name, + quantity: line.quantity, + price: line.price, })), }, }, diff --git a/nextjs_space/lib/drgreen/order-pricing.ts b/nextjs_space/lib/drgreen/order-pricing.ts new file mode 100644 index 00000000..9210dd71 --- /dev/null +++ b/nextjs_space/lib/drgreen/order-pricing.ts @@ -0,0 +1,274 @@ +/** + * Price local order rows from Dr Green, never from the browser (BS-F02). + * + * Dr Green is the system of record for what an order costs. What it returns: + * + * - POST /dapp/orders → `data` is the created Order row: `totalAmount` (line + * items only, in the order's own currency), `deliveryCharge` (billed on top, + * same currency, null on pre-#539 backends), `currency`. No order lines. + * - GET /dapp/orders/:id → `data.orderDetails`. Each `orderLines[]` entry has + * `quantity`, `strain.id` and `localPrice.productAmount`, the LINE total in + * local currency (quantity × unit). `orderDetails.localPrice.totalAmount` is + * the local line-items total. `orderDetails.totalAmount` is NOT the stored + * value: the reader overwrites it with the sum of strain BASE prices (USD), + * so it must never be mirrored into a local-currency column. + * `orderDetails.deliveryCharge` is the stored, locked value. + * + * Until Dr Green's order-line price snapshot (US-P01..P03) ships, the GET + * reader prices lines from the CURRENT catalogue, so its numbers can drift + * from what was charged if a price moves after the order. At creation time + * they agree, which is when BudStacks reads them. + */ +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { logger } from "@/lib/logger"; + +/** Money comparisons: under half a cent is the same amount. */ +const MONEY_EPSILON = 0.005; + +/** A non-negative finite amount, coercing numeric strings; else null. */ +export function moneyOrNull(raw: unknown): number | null { + const value = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw; + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : null; +} + +function differs(a: number, b: number): boolean { + return Math.abs(a - b) >= MONEY_EPSILON; +} + +/** The order-create response's line-items total (local currency). */ +export function subtotalFromCreatedOrder(orderData: unknown): number | null { + return moneyOrNull((orderData as { totalAmount?: unknown } | null)?.totalAmount); +} + +/** `orderDetails` out of a GET /dapp/orders/:id response (single or raw). */ +export function orderDetailsOf(response: unknown): any | null { + const res = response as { data?: { orderDetails?: unknown }; orderDetails?: unknown } | null; + return res?.data?.orderDetails ?? res?.orderDetails ?? null; +} + +/** + * Unit price per strain from Dr Green order lines. `localPrice.productAmount` + * is the line total, so the unit price is productAmount ÷ quantity. + */ +export function unitPricesFromOrderLines(lines: unknown): Map { + const prices = new Map(); + if (!Array.isArray(lines)) return prices; + for (const line of lines) { + const strainId = line?.strain?.id ?? line?.strainId; + const quantity = Number(line?.quantity); + const amount = moneyOrNull(line?.localPrice?.productAmount); + if (typeof strainId === "string" && quantity > 0 && amount !== null) { + prices.set(strainId, amount / quantity); + } + } + return prices; +} + +export interface OrderLineInput { + strainId: string; + quantity: number; + name: string; + /** What the browser showed. Compared and logged, never stored. */ + clientPrice: number | null; +} + +export type LinePriceSource = "drgreen" | "catalogue" | "allocated"; + +export interface PricedOrderLine { + strainId: string; + quantity: number; + name: string; + /** Unit price (per gram), the value written to order_items.price. */ + price: number; + source: LinePriceSource; +} + +export interface PriceMismatch { + strainId: string; + clientPrice: number; + price: number; + source: LinePriceSource; +} + +export interface OrderPricing { + lines: PricedOrderLine[]; + /** Dr Green's line-items total when it gave one, else the lines' sum. */ + subtotal: number; + mismatches: PriceMismatch[]; + /** The priced lines do not add up to Dr Green's total. */ + linesDisagreeWithTotal: boolean; +} + +/** + * Price each line: Dr Green's order line → the server-fetched live catalogue + * → an even per-gram share of whatever part of Dr Green's total the priced + * lines do not account for. The browser's price is never a source. + */ +export function priceOrderLines(params: { + items: readonly OrderLineInput[]; + drGreenUnitPrices: ReadonlyMap; + catalogueUnitPrices?: Readonly>; + drGreenSubtotal: number | null; +}): OrderPricing { + const { items, drGreenUnitPrices, catalogueUnitPrices, drGreenSubtotal } = params; + + const resolved = items.map((item) => { + const fromOrder = drGreenUnitPrices.get(item.strainId); + if (fromOrder !== undefined) return { item, price: fromOrder, source: "drgreen" as const }; + const fromCatalogue = moneyOrNull(catalogueUnitPrices?.[item.strainId]); + if (fromCatalogue !== null) return { item, price: fromCatalogue, source: "catalogue" as const }; + return { item, price: null, source: "allocated" as const }; + }); + + const knownTotal = resolved.reduce( + (sum, r) => sum + (r.price ?? 0) * r.item.quantity, + 0, + ); + const unknownGrams = resolved.reduce( + (sum, r) => sum + (r.price === null ? r.item.quantity : 0), + 0, + ); + const remaining = Math.max(0, (drGreenSubtotal ?? knownTotal) - knownTotal); + const allocatedUnit = unknownGrams > 0 ? remaining / unknownGrams : 0; + + const lines: PricedOrderLine[] = resolved.map((r) => ({ + strainId: r.item.strainId, + quantity: r.item.quantity, + name: r.item.name, + price: r.price ?? allocatedUnit, + source: r.source, + })); + + const linesTotal = lines.reduce((sum, l) => sum + l.price * l.quantity, 0); + const subtotal = drGreenSubtotal ?? linesTotal; + + const mismatches: PriceMismatch[] = lines.flatMap((line, i) => { + const clientPrice = items[i].clientPrice; + return clientPrice !== null && differs(clientPrice, line.price) + ? [{ strainId: line.strainId, clientPrice, price: line.price, source: line.source }] + : []; + }); + + return { + lines, + subtotal, + mismatches, + linesDisagreeWithTotal: drGreenSubtotal !== null && differs(linesTotal, drGreenSubtotal), + }; +} + +/** + * Price a just-created Dr Green order. Reads lines off the create response + * when it carries them, else GET /dapp/orders/:id. Never throws — the Dr Green + * order already exists, so a failed read must not fail the checkout; it falls + * back to the server-side catalogue and is logged. + */ +export async function resolveOrderPricing(params: { + orderData: any; + items: readonly OrderLineInput[]; + catalogueUnitPrices?: Readonly>; + apiKey: string; + secretKey: string; + apiUrl?: string; + requestId?: string; +}): Promise { + const { orderData, items, catalogueUnitPrices, apiKey, secretKey, apiUrl, requestId } = params; + const drGreenOrderId: string = orderData.id; + + let drGreenUnitPrices = unitPricesFromOrderLines(orderData?.orderLines); + const coversAll = items.every((i) => drGreenUnitPrices.has(i.strainId)); + if (!coversAll) { + try { + const res = await callDrGreenAPI(`/dapp/orders/${drGreenOrderId}`, { + method: "GET", + apiKey, + secretKey, + baseUrl: apiUrl, + // GET with a path param → DualAuthGuard signs JSON.stringify(req.params). + signBody: { orderId: drGreenOrderId }, + }); + drGreenUnitPrices = unitPricesFromOrderLines(orderDetailsOf(res)?.orderLines); + } catch (err) { + logger.warn("[orders] could not read Dr Green order lines; pricing from the live catalogue", { + requestId, + drGreenOrderId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const pricing = priceOrderLines({ + items, + drGreenUnitPrices, + catalogueUnitPrices, + drGreenSubtotal: subtotalFromCreatedOrder(orderData), + }); + + if (pricing.mismatches.length > 0) { + // Dr Green wins. A difference here means the customer saw a different + // price than Dr Green charged — a stale basket or a price move. + logger.warn("[orders] browser price differs from Dr Green price; Dr Green wins", { + requestId, + drGreenOrderId, + mismatches: pricing.mismatches, + }); + } + if (pricing.linesDisagreeWithTotal) { + logger.warn("[orders] order lines do not add up to Dr Green's total", { + requestId, + drGreenOrderId, + subtotal: pricing.subtotal, + sources: pricing.lines.map((l) => l.source), + }); + } + return pricing; +} + +// ── Sync (syncOneOrder) ───────────────────────────────────────────────────── + +export interface OrderTotals { + subtotal: number | null; + shippingCost: number | null; + total: number | null; +} + +/** + * Totals from GET /dapp/orders/:id `orderDetails`: the LOCAL line-items total + * (`localPrice.totalAmount`, not the overwritten USD `totalAmount`) and the + * stored `deliveryCharge` (null on orders placed before it was locked). + */ +export function totalsFromOrderDetails(details: unknown): { + subtotal: number | null; + deliveryCharge: number | null; +} { + const d = details as { localPrice?: { totalAmount?: unknown }; deliveryCharge?: unknown } | null; + return { + subtotal: moneyOrNull(d?.localPrice?.totalAmount), + deliveryCharge: moneyOrNull(d?.deliveryCharge), + }; +} + +/** + * The column changes that make a local row carry Dr Green's totals. A field + * Dr Green did not report keeps the local value. Only differences are + * returned, so an in-step row produces no write. + */ +export function planTotalsUpdate( + current: OrderTotals, + fromDrGreen: { subtotal: number | null; deliveryCharge: number | null }, +): Partial<{ subtotal: number; shippingCost: number; total: number }> { + const subtotal = fromDrGreen.subtotal ?? current.subtotal; + const shippingCost = fromDrGreen.deliveryCharge ?? current.shippingCost; + if (subtotal === null || shippingCost === null) return {}; + const total = subtotal + shippingCost; + + const update: Partial<{ subtotal: number; shippingCost: number; total: number }> = {}; + if (current.subtotal === null || differs(subtotal, current.subtotal)) update.subtotal = subtotal; + if (current.shippingCost === null || differs(shippingCost, current.shippingCost)) { + update.shippingCost = shippingCost; + } + if (current.total === null || differs(total, current.total)) update.total = total; + return update; +} diff --git a/nextjs_space/lib/orders/storefront-orders.ts b/nextjs_space/lib/orders/storefront-orders.ts index f01c90b9..74dca262 100644 --- a/nextjs_space/lib/orders/storefront-orders.ts +++ b/nextjs_space/lib/orders/storefront-orders.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; import { logger } from "@/lib/logger"; +import { + planTotalsUpdate, + totalsFromOrderDetails, + type OrderTotals, +} from "@/lib/drgreen/order-pricing"; import type { StorefrontOrder } from "./order-presentation"; // Re-export the pure types so server callers can import them from here too. @@ -66,19 +71,22 @@ type OrderRow = { }[]; }; +type SyncableOrder = OrderTotals & { + id: string; + drGreenOrderId: string; + paymentStatus: string; + status: string; + drGreenInvoiceNum: string | null; +}; + /** * Pull one order's live status from Dr Green and persist any change. Best-effort * — never throws — so a Dr Green hiccup leaves the cached row untouched. Mirrors - * getOrder()'s payment sync and additionally tracks fulfilment (orderStatus). + * getOrder()'s payment sync and additionally tracks fulfilment (orderStatus) + * and, since BS-F02, the totals (Dr Green is the source of truth for money). */ -async function syncOneOrder( - order: { - id: string; - drGreenOrderId: string; - paymentStatus: string; - status: string; - drGreenInvoiceNum: string | null; - }, +export async function syncOneOrder( + order: SyncableOrder, config: DrGreenStorefrontConfig, ): Promise { try { @@ -93,11 +101,22 @@ async function syncOneOrder( const details = res?.data?.orderDetails ?? res?.orderDetails ?? res?.data ?? res ?? {}; + // BS-F02: mirror Dr Green's totals. Reads the LOCAL line-items total + // (localPrice.totalAmount — orderDetails.totalAmount is overwritten with + // the USD base sum by Dr Green's reader) and the stored deliveryCharge. + const totals = planTotalsUpdate( + { subtotal: order.subtotal, shippingCost: order.shippingCost, total: order.total }, + totalsFromOrderDetails(details), + ); + const data: { paymentStatus?: string; status?: string; drGreenInvoiceNum?: string; - } = {}; + subtotal?: number; + shippingCost?: number; + total?: number; + } = { ...totals }; const pay = typeof details?.paymentStatus === "string" @@ -127,6 +146,12 @@ async function syncOneOrder( } if (Object.keys(data).length > 0) { + if (Object.keys(totals).length > 0) { + logger.info(`[orders] totals synced from Dr Green for ${order.id}`, { + before: { subtotal: order.subtotal, shippingCost: order.shippingCost, total: order.total }, + after: totals, + }); + } await prisma.orders.update({ where: { id: order.id }, data }); } } catch (err) { @@ -197,6 +222,9 @@ export async function listUserOrdersWithSync(params: { paymentStatus: o.paymentStatus, status: o.status, drGreenInvoiceNum: o.drGreenInvoiceNum, + subtotal: o.subtotal, + shippingCost: o.shippingCost, + total: o.total, }, config, ), @@ -226,6 +254,9 @@ export async function syncOrderById( paymentStatus: true, status: true, drGreenInvoiceNum: true, + subtotal: true, + shippingCost: true, + total: true, }, }); if (!o?.drGreenOrderId) return; @@ -236,6 +267,9 @@ export async function syncOrderById( paymentStatus: o.paymentStatus, status: o.status, drGreenInvoiceNum: o.drGreenInvoiceNum, + subtotal: o.subtotal, + shippingCost: o.shippingCost, + total: o.total, }, config, ); diff --git a/nextjs_space/tests/unit/order-pricing.test.ts b/nextjs_space/tests/unit/order-pricing.test.ts new file mode 100644 index 00000000..8b2de210 --- /dev/null +++ b/nextjs_space/tests/unit/order-pricing.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: vi.fn() })); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + moneyOrNull, + orderDetailsOf, + planTotalsUpdate, + priceOrderLines, + subtotalFromCreatedOrder, + totalsFromOrderDetails, + unitPricesFromOrderLines, +} from "@/lib/drgreen/order-pricing"; + +/** + * BS-F02: local order rows carry Dr Green's numbers. Fixtures follow the + * shapes dr-green-backend returns today (order.service.ts createOrder / + * getOrderById): the create response is the Order row with no lines; the + * detail response is data.orderDetails with lines whose + * localPrice.productAmount is the LINE total. + */ +const detailResponse = { + success: true, + data: { + orderDetails: { + id: "dg-1", + totalAmount: 50, // USD base sum — overwritten by Dr Green's reader + deliveryCharge: 110, + currency: "ZAR", + localPrice: { currency: "ZAR", totalAmount: 1185 }, + orderLines: [ + { quantity: 5, strain: { id: "s-1" }, localPrice: { productAmount: 825 } }, + { quantity: 2, strain: { id: "s-2" }, localPrice: { productAmount: 360 } }, + ], + }, + }, +}; + +const input = (strainId: string, quantity: number, clientPrice: number | null = null) => ({ + strainId, + quantity, + name: strainId, + clientPrice, +}); + +describe("reading Dr Green responses", () => { + it("unwraps orderDetails and turns line totals into unit prices", () => { + const prices = unitPricesFromOrderLines(orderDetailsOf(detailResponse)?.orderLines); + expect(prices.get("s-1")).toBe(165); + expect(prices.get("s-2")).toBe(180); + }); + + it("skips lines it cannot price rather than guessing", () => { + const prices = unitPricesFromOrderLines([ + { quantity: 0, strain: { id: "zero" }, localPrice: { productAmount: 10 } }, + { quantity: 2, strain: { id: "no-price" } }, + { quantity: 2, localPrice: { productAmount: 10 } }, + ]); + expect(prices.size).toBe(0); + expect(unitPricesFromOrderLines(undefined).size).toBe(0); + }); + + it("reads the create response's line-items total", () => { + expect(subtotalFromCreatedOrder({ id: "dg-1", totalAmount: 1185, deliveryCharge: 110 })).toBe(1185); + expect(subtotalFromCreatedOrder({ id: "dg-1" })).toBeNull(); + expect(moneyOrNull("12.5")).toBe(12.5); + expect(moneyOrNull(-1)).toBeNull(); + expect(moneyOrNull("")).toBeNull(); + }); + + it("syncs from localPrice.totalAmount, never the overwritten USD totalAmount", () => { + expect(totalsFromOrderDetails(orderDetailsOf(detailResponse))).toEqual({ + subtotal: 1185, + deliveryCharge: 110, + }); + }); +}); + +describe("priceOrderLines", () => { + it("prices every line from Dr Green and ignores the browser's price", () => { + const result = priceOrderLines({ + items: [input("s-1", 5, 200), input("s-2", 2, 180)], + drGreenUnitPrices: unitPricesFromOrderLines(orderDetailsOf(detailResponse)?.orderLines), + drGreenSubtotal: 1185, + }); + expect(result.lines.map((l) => [l.strainId, l.price, l.source])).toEqual([ + ["s-1", 165, "drgreen"], + ["s-2", 180, "drgreen"], + ]); + expect(result.subtotal).toBe(1185); + expect(result.linesDisagreeWithTotal).toBe(false); + // Only the line whose browser price differs is reported. + expect(result.mismatches).toEqual([ + { strainId: "s-1", clientPrice: 200, price: 165, source: "drgreen" }, + ]); + }); + + it("falls back to the server catalogue, then to the unaccounted part of Dr Green's total", () => { + const result = priceOrderLines({ + items: [input("s-1", 5), input("s-2", 2), input("s-3", 4)], + drGreenUnitPrices: new Map([["s-1", 165]]), + catalogueUnitPrices: { "s-2": 180 }, + drGreenSubtotal: 1185 + 400, + }); + expect(result.lines.map((l) => [l.price, l.source])).toEqual([ + [165, "drgreen"], + [180, "catalogue"], + [100, "allocated"], // (1585 − 825 − 360) ÷ 4 g + ]); + expect(result.subtotal).toBe(1585); + expect(result.linesDisagreeWithTotal).toBe(false); + }); + + it("keeps Dr Green's subtotal and flags lines that do not add up to it", () => { + const result = priceOrderLines({ + items: [input("s-1", 5)], + drGreenUnitPrices: new Map(), + catalogueUnitPrices: { "s-1": 160 }, + drGreenSubtotal: 825, + }); + expect(result.subtotal).toBe(825); + expect(result.lines[0].price).toBe(160); + expect(result.linesDisagreeWithTotal).toBe(true); + }); + + it("uses the lines' sum when Dr Green gave no total (older backend)", () => { + const result = priceOrderLines({ + items: [input("s-1", 5, 999)], + drGreenUnitPrices: new Map([["s-1", 165]]), + drGreenSubtotal: null, + }); + expect(result.subtotal).toBe(825); + expect(result.linesDisagreeWithTotal).toBe(false); + }); +}); + +describe("planTotalsUpdate", () => { + const current = { subtotal: 1000, shippingCost: 5, total: 1005 }; + + it("moves every total that differs from Dr Green's", () => { + expect(planTotalsUpdate(current, { subtotal: 1185, deliveryCharge: 110 })).toEqual({ + subtotal: 1185, + shippingCost: 110, + total: 1295, + }); + }); + + it("writes nothing when the row is already in step", () => { + expect( + planTotalsUpdate({ subtotal: 1185, shippingCost: 110, total: 1295 }, { subtotal: 1185, deliveryCharge: 110 }), + ).toEqual({}); + }); + + it("keeps the local value of a field Dr Green did not report (pre-#539 delivery)", () => { + expect(planTotalsUpdate(current, { subtotal: 1185, deliveryCharge: null })).toEqual({ + subtotal: 1185, + total: 1190, + }); + expect(planTotalsUpdate(current, { subtotal: null, deliveryCharge: null })).toEqual({}); + }); +}); diff --git a/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts b/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts new file mode 100644 index 00000000..36656a33 --- /dev/null +++ b/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * BS-F02: syncOneOrder mirrors Dr Green's totals onto the local row when they + * differ — the LOCAL line-items total (localPrice.totalAmount) and the stored + * deliveryCharge, never orderDetails.totalAmount (the reader's USD base sum). + */ +const prismaMock = vi.hoisted(() => ({ orders: { update: vi.fn() } })); +const apiMock = vi.hoisted(() => ({ callDrGreenAPI: vi.fn() })); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/drgreen/drgreen-api-client", () => apiMock); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { syncOneOrder } from "@/lib/orders/storefront-orders"; + +const CONFIG = { apiKey: "k", secretKey: "s", apiUrl: "https://stage/api/v1" }; + +const row = { + id: "order-1", + drGreenOrderId: "dg-1", + paymentStatus: "PENDING", + status: "PENDING", + drGreenInvoiceNum: "INV-1", + // Priced from a stale browser basket, with the old hardcoded delivery. + subtotal: 1000, + shippingCost: 5, + total: 1005, +}; + +const details = (over: Record = {}) => ({ + success: true, + data: { + orderDetails: { + id: "dg-1", + paymentStatus: "PENDING", + orderStatus: "PENDING", + invoiceNumber: "INV-1", + totalAmount: 50, // USD base — must not be mirrored + deliveryCharge: 110, + localPrice: { currency: "ZAR", totalAmount: 1185 }, + ...over, + }, + }, +}); + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.orders.update.mockResolvedValue({}); +}); + +describe("syncOneOrder — totals", () => { + it("mirrors Dr Green's subtotal, delivery and total", async () => { + apiMock.callDrGreenAPI.mockResolvedValue(details()); + await syncOneOrder(row, CONFIG); + expect(prismaMock.orders.update).toHaveBeenCalledWith({ + where: { id: "order-1" }, + data: { subtotal: 1185, shippingCost: 110, total: 1295 }, + }); + }); + + it("writes nothing when the row already matches", async () => { + apiMock.callDrGreenAPI.mockResolvedValue(details()); + await syncOneOrder({ ...row, subtotal: 1185, shippingCost: 110, total: 1295 }, CONFIG); + expect(prismaMock.orders.update).not.toHaveBeenCalled(); + }); + + it("keeps the local delivery when Dr Green has none locked (older order)", async () => { + apiMock.callDrGreenAPI.mockResolvedValue(details({ deliveryCharge: null })); + await syncOneOrder(row, CONFIG); + expect(prismaMock.orders.update).toHaveBeenCalledWith({ + where: { id: "order-1" }, + data: { subtotal: 1185, total: 1190 }, + }); + }); + + it("syncs totals alongside a status change in one write", async () => { + apiMock.callDrGreenAPI.mockResolvedValue(details({ paymentStatus: "PAID" })); + await syncOneOrder(row, CONFIG); + expect(prismaMock.orders.update).toHaveBeenCalledWith({ + where: { id: "order-1" }, + data: { subtotal: 1185, shippingCost: 110, total: 1295, paymentStatus: "PAID" }, + }); + }); + + it("never throws when Dr Green is unreachable", async () => { + apiMock.callDrGreenAPI.mockRejectedValue(new Error("502")); + await expect(syncOneOrder(row, CONFIG)).resolves.toBeUndefined(); + expect(prismaMock.orders.update).not.toHaveBeenCalled(); + }); +}); diff --git a/nextjs_space/tests/unit/submit-order-pricing.test.ts b/nextjs_space/tests/unit/submit-order-pricing.test.ts new file mode 100644 index 00000000..95c4f43f --- /dev/null +++ b/nextjs_space/tests/unit/submit-order-pricing.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * BS-F02: submitOrder writes orders.subtotal/shippingCost/total and + * order_items.price from Dr Green's order, never from the request body. A + * browser price that differs is logged at warn and loses. + */ +const prismaMock = vi.hoisted(() => ({ + users: { findUnique: vi.fn() }, + drgreen_carts: { findUnique: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn() }, + orders: { create: vi.fn() }, +})); +const apiMock = vi.hoisted(() => ({ callDrGreenAPI: vi.fn() })); +const loggerMock = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/drgreen/drgreen-api-client", () => apiMock); +vi.mock("@/lib/logger", () => ({ logger: loggerMock })); +vi.mock("@/lib/drgreen/drgreen-client-cart", () => ({ + getClientCartId: vi.fn(async () => "client-cart-1"), + invalidateClientCartId: vi.fn(async () => undefined), +})); + +import { submitOrder } from "@/lib/drgreen/drgreen-orders"; + +const SHIPPING = { + address1: "1 Long St", + city: "Cape Town", + state: "WC", + postalCode: "8001", + country: "South Africa", +}; + +// What the browser sends today: the add-to-cart price, possibly stale. +const clientCartItems = [ + { strainId: "s-1", quantity: 5, strain: { id: "s-1", name: "Strain One", retailPrice: 200 } }, + { strainId: "s-2", quantity: 2, strain: { id: "s-2", name: "Strain Two", retailPrice: 180 } }, +]; + +// dr-green-backend createOrder → the Order row (no lines), wrapped in `data`. +const createResponse = { + success: true, + data: { id: "dg-1", invoiceNumber: "INV-1", totalAmount: 1185, deliveryCharge: 110, currency: "ZAR" }, +}; +// getOrderById → data.orderDetails; localPrice.productAmount is the line total. +const detailResponse = { + success: true, + data: { + orderDetails: { + id: "dg-1", + totalAmount: 50, + deliveryCharge: 110, + localPrice: { currency: "ZAR", totalAmount: 1185 }, + orderLines: [ + { quantity: 5, strain: { id: "s-1" }, localPrice: { productAmount: 825 } }, + { quantity: 2, strain: { id: "s-2" }, localPrice: { productAmount: 360 } }, + ], + }, + }, +}; + +function routeDrGreen(detail: unknown | Error = detailResponse) { + apiMock.callDrGreenAPI.mockImplementation(async (endpoint: string, opts: any) => { + if (endpoint === "/dapp/carts" && opts.method === "POST") return { success: true }; + if (endpoint === "/dapp/orders" && opts.method === "POST") return createResponse; + if (endpoint === "/dapp/orders/dg-1" && opts.method === "GET") { + if (detail instanceof Error) throw detail; + return detail; + } + throw new Error(`unexpected ${opts.method} ${endpoint}`); + }); +} + +const baseParams = { + userId: "user-1", + tenantId: "tenant-1", + shippingInfo: SHIPPING, + apiKey: "k", + secretKey: "s", + apiUrl: "https://stage/api/v1", + clientCartItems, +}; + +function savedOrder() { + return prismaMock.orders.create.mock.calls[0][0].data; +} + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.users.findUnique.mockResolvedValue({ drGreenClientId: "client-1", email: "a@b.c" }); + prismaMock.drgreen_carts.findUnique.mockResolvedValue(null); + prismaMock.drgreen_carts.upsert.mockResolvedValue({}); + prismaMock.drgreen_carts.deleteMany.mockResolvedValue({ count: 1 }); + prismaMock.orders.create.mockImplementation(async ({ data }: any) => ({ + ...data, + order_items: data.order_items.create, + })); + routeDrGreen(); +}); + +describe("submitOrder — the local row is priced by Dr Green", () => { + it("writes Dr Green's totals and line prices, not the browser's", async () => { + const result = await submitOrder(baseParams); + + const data = savedOrder(); + expect(data.subtotal).toBe(1185); + expect(data.shippingCost).toBe(110); + expect(data.total).toBe(1295); + expect(result.total).toBe(1295); + expect(data.order_items.create.map((i: any) => [i.productId, i.productName, i.quantity, i.price])).toEqual([ + ["s-1", "Strain One", 5, 165], + ["s-2", "Strain Two", 2, 180], + ]); + // The detail read is the signed GET the rest of the code uses. + expect(apiMock.callDrGreenAPI).toHaveBeenCalledWith( + "/dapp/orders/dg-1", + expect.objectContaining({ method: "GET", signBody: { orderId: "dg-1" }, baseUrl: "https://stage/api/v1" }), + ); + }); + + it("logs the browser-vs-Dr Green difference at warn, and Dr Green wins", async () => { + await submitOrder(baseParams); + + const warn = loggerMock.warn.mock.calls.find(([msg]) => /differs from Dr Green/.test(msg)); + expect(warn).toBeDefined(); + expect(warn![1].mismatches).toEqual([ + { strainId: "s-1", clientPrice: 200, price: 165, source: "drgreen" }, + ]); + expect(savedOrder().order_items.create[0].price).toBe(165); + }); + + it("still saves the order when the detail read fails, from the server catalogue", async () => { + routeDrGreen(new Error("Doctor Green API Error: 502")); + await submitOrder({ ...baseParams, catalogueUnitPrices: { "s-1": 165, "s-2": 180 } }); + + const data = savedOrder(); + expect(data.subtotal).toBe(1185); + expect(data.order_items.create.map((i: any) => i.price)).toEqual([165, 180]); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.stringMatching(/could not read Dr Green order lines/), + expect.objectContaining({ drGreenOrderId: "dg-1" }), + ); + }); + + // PRD BS-F02: analytics needs no change once rows are right. It sums + // orders.total for revenue and order_items.price × quantity for product + // revenue (app/api/tenant-admin/analytics/route.ts); both now equal Dr + // Green's figures for the order. + it("produces a row whose analytics sums equal Dr Green's totals", async () => { + await submitOrder(baseParams); + const data = savedOrder(); + const productRevenue = data.order_items.create.reduce( + (sum: number, i: any) => sum + i.price * i.quantity, + 0, + ); + expect(productRevenue).toBe(createResponse.data.totalAmount); + expect(data.total).toBe(createResponse.data.totalAmount + createResponse.data.deliveryCharge); + }); +}); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md index 80a74bc9..d178c1ec 100644 --- a/tasks/prd-drgreen-commission-flex.md +++ b/tasks/prd-drgreen-commission-flex.md @@ -15,7 +15,7 @@ ## 0. Status (2026-10-05) -BS-F01 is built on the branch above with unit tests. Not yet done, and why: +BS-F01 and BS-F02 are built on the branch above with unit tests. Not yet done, and why: - **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`. - **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. @@ -24,6 +24,8 @@ BS-F01 is built on the branch above with unit tests. Not yet done, and why: 1. **GET `/dapp/carts` ignores `clientId`.** `GetCartsDto` has only `search` (plus pagination), the whitelist strips the rest, and the list is every client of the key with a non-empty cart, newest first, ten per page (`dr-green-backend src/carts/carts.service.ts getCartList`). `getCart` read `data.clients[0]` — whichever customer of the store touched a cart last — and wrote that cart into the signed-in user's `drgreen_carts` mirror. Fixed with `pickClientCart(response, clientId)` (`lib/drgreen/delivery-quote.ts`), used by `getCart` and the new quote. 2. **There was no "existing checkout quote path".** Nothing in BudStacks read `localPrices.deliveryCharge`, and the catalogue (`/dapp/strains`) does not carry the delivery charge. Added `GET /api/store/[slug]/checkout/quote` (signed `GET /dapp/carts?search=`, picked by client id). **Expect "Calculated by Dr Green" on most checkouts:** BudStacks keeps the basket in the browser and only pushes it to Dr Green at submit, and Dr Green empties the server cart when an order is created, so the customer usually has no server cart to quote from. Showing the real charge every time needs either a Dr Green delivery-quote endpoint (e.g. `deliveryCharge` on the `/dapp/strains` location select) or pushing the basket to Dr Green's cart at checkout load — see §8. +3. **`orderDetails.totalAmount` from `GET /dapp/orders/:id` is not the stored order total.** `getOrderById` selects `totalAmount` and then overwrites it with Σ `strain.retailPrice × quantity` — the strain **base** (USD) price — and sets `deliveryFee` to the constant. Mirroring it as the PRD said would write a USD figure into a rand column. The local-currency total is `orderDetails.localPrice.totalAmount`; `orderDetails.deliveryCharge` is the stored, locked value. The create response (`POST /dapp/orders`) is the raw Order row, so its `totalAmount` is the stored local figure — that is what `submitOrder` uses. +4. **Until Dr Green's order-line price snapshot ships (`order-line-price-snapshot.prd.md` US-P01..P03), the GET reader prices lines and `localPrice.totalAmount` from the strain's CURRENT price** (matched on the client's current shipping country). At creation time this equals what Dr Green stored, which is when `submitOrder` and the post-mint `syncOrderById` read it. A later `syncOneOrder` (customer opens orders/dashboard) on a still-unsettled order would follow a price change made after the order was placed. Commission Flex depends on P01..P03 landing first, so this closes before any per-KEY price exists; between now and then it only matters if Dr Green changes a list price while an order is unpaid. Historic rows (priced from the browser) on non-terminal orders are corrected by the same sync. ## 1. Introduction / Overview @@ -57,15 +59,15 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): - [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* - [ ] Verify in browser on a tenant after deploy (no staging) with a test account. -### BS-F02: Local order row priced from Dr Green's response +### BS-F02: Local order row priced from Dr Green's response — ✅ built **Description:** As a tenant admin and a customer, I want order history to show what was charged. **Acceptance Criteria:** -- [ ] `submitOrder` (`lib/drgreen/drgreen-orders.ts`) prices `order_items.price` and `orders.subtotal/total` from Dr Green's order response (`totalAmount`, `deliveryCharge`, and per-line `localPrice.productAmount` from `GET /dapp/orders/:id` when the create response lacks lines), never from the request body. The client-sent `strain.retailPrice` is ignored for pricing (kept only for the product name/image fallback). -- [ ] `syncOneOrder` also mirrors `totalAmount` and `deliveryCharge` into `subtotal`, `shippingCost`, `total` when they differ (Dr Green is the source of truth). -- [ ] Analytics revenue (`app/api/tenant-admin/analytics/route.ts`) needs no change once rows are correct; confirm with a test fixture. -- [ ] Unit tests: response-priced row; mismatch between client price and Dr Green price → Dr Green wins and the difference is logged at warn. -- [ ] Typecheck/lint passes. +- [x] `submitOrder` (`lib/drgreen/drgreen-orders.ts`) prices `order_items.price` and `orders.subtotal/total` from Dr Green's order response (`totalAmount`, `deliveryCharge`, and per-line `localPrice.productAmount` from `GET /dapp/orders/:id` when the create response lacks lines), never from the request body. The client-sent `strain.retailPrice` is ignored for pricing (kept only for the product name fallback and the warn log). — `lib/drgreen/order-pricing.ts` `resolveOrderPricing`. The create response carries no lines today, so every order makes the extra signed GET. `productAmount` is the **line** total, so `order_items.price` (per gram, as analytics expects) is `productAmount ÷ quantity`. If the GET fails, a line is priced from the server-fetched live catalogue the submit route already loads (`catalogueUnitPrices`), then from the part of Dr Green's total the priced lines do not cover; `orders.subtotal` is always Dr Green's `totalAmount` when it sent one. The browser's price is never a source. +- [x] `syncOneOrder` also mirrors ~~`totalAmount`~~ the local line-items total and `deliveryCharge` into `subtotal`, `shippingCost`, `total` when they differ (Dr Green is the source of truth). — Reads `orderDetails.localPrice.totalAmount`, **not** `orderDetails.totalAmount` (see §0 correction 3). A field Dr Green does not report (null `deliveryCharge` on orders placed before it was locked) keeps the local value. +- [x] Analytics revenue (`app/api/tenant-admin/analytics/route.ts`) needs no change once rows are correct; confirm with a test fixture. — It sums `orders.total` and `order_items.price × quantity`; `tests/unit/submit-order-pricing.test.ts` asserts both equal Dr Green's figures for a priced row. +- [x] Unit tests: response-priced row; mismatch between client price and Dr Green price → Dr Green wins and the difference is logged at warn. — `tests/unit/submit-order-pricing.test.ts`, `tests/unit/order-pricing.test.ts`, `tests/unit/storefront-orders-sync-totals.test.ts`. +- [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* ### BS-F03: Tenant-scoped product cache **Acceptance Criteria:** From 90ce0c16c2df4105777a98756d94c6808d31c382 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 11:58:37 +0100 Subject: [PATCH 3/6] fix(catalogue): scope the 60s product cache to the tenant's API key (BS-F03) fetchProduct cached the normalised catalogue under ${country}:${apiUrl}, so once Dr Green Commission Flex gives each KEY its own price, one tenant's product page metadata and JSON-LD Offer.price could carry another tenant's price for up to 60s. The key now starts with a SHA-256 prefix of the API key; platform-key fallback tenants share the platform entry, and invalidateProductCache still clears every entry. Cache moved to lib/drgreen/product-cache.ts. --- nextjs_space/lib/drgreen/doctor-green-api.ts | 36 ++++---- nextjs_space/lib/drgreen/product-cache.ts | 53 +++++++++++ .../unit/product-cache-tenant-scope.test.ts | 88 +++++++++++++++++++ tasks/prd-drgreen-commission-flex.md | 12 +-- 4 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 nextjs_space/lib/drgreen/product-cache.ts create mode 100644 nextjs_space/tests/unit/product-cache-tenant-scope.test.ts diff --git a/nextjs_space/lib/drgreen/doctor-green-api.ts b/nextjs_space/lib/drgreen/doctor-green-api.ts index b514665a..e363d68a 100644 --- a/nextjs_space/lib/drgreen/doctor-green-api.ts +++ b/nextjs_space/lib/drgreen/doctor-green-api.ts @@ -5,6 +5,11 @@ import { callDrGreenAPI } from '@/lib/drgreen/drgreen-api-client'; import { convertFromEUR } from '@/lib/exchange-rates'; +import { + getCachedProducts, + productCacheKey, + setCachedProducts, +} from '@/lib/drgreen/product-cache'; const API_URL = process.env.DOCTOR_GREEN_API_URL || 'https://api.drgreennft.com/api/v1'; @@ -376,34 +381,25 @@ export async function fetchProducts( return Promise.all(products.map((product: DoctorGreenProduct) => normalizeProduct(product, country))); } -// In-memory product cache to avoid re-fetching all products for single lookups -const productCache = new Map(); -const PRODUCT_CACHE_TTL_MS = 60 * 1000; // 60s — short, and busted on Dr Green strain/inventory webhooks - -/** - * Clear the in-memory product cache. Called from the Dr Green webhook so a - * strain/inventory change (including a strain recreated with a new id) is - * reflected immediately instead of after the TTL. - */ -export function invalidateProductCache(): void { - productCache.clear(); -} +// Tenant-scoped catalogue cache for single-product lookups (BS-F03) — see +// lib/drgreen/product-cache.ts. Re-exported so existing importers (the Dr +// Green webhook) keep working. +export { invalidateProductCache } from '@/lib/drgreen/product-cache'; export async function fetchProduct( productId: string, country: string = "ZA", config: DoctorGreenConfig, ): Promise { - // /strains/{id} requires auth that doesn't work — use the cached product list - const cacheKey = `${country}:${config.apiUrl}`; - const cached = productCache.get(cacheKey); - let allProducts: DoctorGreenProduct[]; + // /strains/{id} requires auth that doesn't work — use the cached product + // list, keyed by the tenant's API key so one tenant's price is never served + // to another. + const cacheKey = productCacheKey(country, config); + let allProducts = getCachedProducts(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - allProducts = cached.products; - } else { + if (!allProducts) { allProducts = await fetchProducts(country, config); - productCache.set(cacheKey, { products: allProducts, expiresAt: Date.now() + PRODUCT_CACHE_TTL_MS }); + setCachedProducts(cacheKey, allProducts); } const product = allProducts.find(p => p.id === productId); diff --git a/nextjs_space/lib/drgreen/product-cache.ts b/nextjs_space/lib/drgreen/product-cache.ts new file mode 100644 index 00000000..f1fec2ab --- /dev/null +++ b/nextjs_space/lib/drgreen/product-cache.ts @@ -0,0 +1,53 @@ +/** + * In-memory cache of a tenant's normalised Dr Green catalogue, used by + * fetchProduct so a product page does not re-fetch the whole list (BS-F03). + * + * Entries are keyed by the API key that fetched them. With Dr Green + * Commission Flex the catalogue a key receives carries THAT key's price, so a + * key-less entry would serve one tenant's price on another tenant's product + * page (metadata and JSON-LD Offer.price) for up to the TTL. Tenants on the + * platform-key fallback share the platform key's entry, which is correct: + * they are served the platform key's price. + * + * The key holds a SHA-256 prefix of the API key, never the key itself. + */ +import { createHash } from "crypto"; + +const PRODUCT_CACHE_TTL_MS = 60 * 1000; // 60s — short, and busted on Dr Green strain/inventory webhooks + +interface CacheEntry { + products: T[]; + expiresAt: number; +} + +const productCache = new Map>(); + +/** A stable, non-reversible id for the key that fetched a catalogue. */ +export function tenantKeyId(apiKey: string): string { + return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); +} + +export function productCacheKey( + country: string, + config: { apiKey: string; apiUrl?: string }, +): string { + return `${tenantKeyId(config.apiKey)}:${country}:${config.apiUrl}`; +} + +export function getCachedProducts(key: string, now = Date.now()): T[] | null { + const entry = productCache.get(key); + return entry && entry.expiresAt > now ? (entry.products as T[]) : null; +} + +export function setCachedProducts(key: string, products: T[], now = Date.now()): void { + productCache.set(key, { products, expiresAt: now + PRODUCT_CACHE_TTL_MS }); +} + +/** + * Clear the in-memory product cache — every tenant's entry. Called from the + * Dr Green webhook so a strain/inventory change (including a strain recreated + * with a new id) is reflected immediately instead of after the TTL. + */ +export function invalidateProductCache(): void { + productCache.clear(); +} diff --git a/nextjs_space/tests/unit/product-cache-tenant-scope.test.ts b/nextjs_space/tests/unit/product-cache-tenant-scope.test.ts new file mode 100644 index 00000000..903821c7 --- /dev/null +++ b/nextjs_space/tests/unit/product-cache-tenant-scope.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: vi.fn() })); +vi.mock("@/lib/exchange-rates", () => ({ + convertFromEUR: vi.fn(async (value: number) => value), +})); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { fetchProduct, invalidateProductCache } from "@/lib/drgreen/doctor-green-api"; +import { productCacheKey } from "@/lib/drgreen/product-cache"; + +/** + * BS-F03: with Dr Green Commission Flex each KEY's catalogue carries its own + * price, so fetchProduct's 60 s cache must never serve one tenant's price to + * another. Two tenants, same country, different keys → independent entries. + */ +const API_URL = "https://api.example/api/v1"; +const tenantA = { apiKey: "key-tenant-a", secretKey: "s", apiUrl: API_URL }; +const tenantB = { apiKey: "key-tenant-b", secretKey: "s", apiUrl: API_URL }; + +const strainAt = (price: number) => ({ + id: "s-1", + name: "Strain One", + description: "", + thc: 20, + cbd: 1, + type: "Indica", + retailPrice: 10, + isActive: true, + strainLocations: [ + { isActive: true, isAvailable: true, stockQuantity: 5, retailPrice: price, location: { currency: "ZAR" } }, + ], +}); + +beforeEach(() => { + vi.clearAllMocks(); + invalidateProductCache(); + // Each key gets its own (Flex-reduced) price for the same strain. + (callDrGreenAPI as any).mockImplementation(async (_endpoint: string, opts: any) => ({ + data: { strains: [strainAt(opts.apiKey === tenantA.apiKey ? 165 : 132)] }, + })); +}); + +describe("fetchProduct cache scoping", () => { + it("keeps two tenants with different keys in independent entries", async () => { + const a = await fetchProduct("s-1", "ZA", tenantA); + const b = await fetchProduct("s-1", "ZA", tenantB); + expect(a.price).toBe(165); + expect(b.price).toBe(132); + expect(callDrGreenAPI).toHaveBeenCalledTimes(2); + + // Each tenant's second lookup is served from its own entry. + expect((await fetchProduct("s-1", "ZA", tenantA)).price).toBe(165); + expect((await fetchProduct("s-1", "ZA", tenantB)).price).toBe(132); + expect(callDrGreenAPI).toHaveBeenCalledTimes(2); + }); + + it("shares one entry between tenants on the same (platform) key", async () => { + const platformA = { apiKey: "platform-key", secretKey: "s", apiUrl: API_URL }; + const platformB = { ...platformA }; + await fetchProduct("s-1", "ZA", platformA); + await fetchProduct("s-1", "ZA", platformB); + expect(callDrGreenAPI).toHaveBeenCalledTimes(1); + }); + + it("invalidateProductCache still clears every tenant's entry", async () => { + await fetchProduct("s-1", "ZA", tenantA); + await fetchProduct("s-1", "ZA", tenantB); + invalidateProductCache(); + await fetchProduct("s-1", "ZA", tenantA); + await fetchProduct("s-1", "ZA", tenantB); + expect(callDrGreenAPI).toHaveBeenCalledTimes(4); + }); +}); + +describe("productCacheKey", () => { + it("is `${tenantKey}:${country}:${apiUrl}` and never contains the raw key", () => { + const key = productCacheKey("ZA", tenantA); + expect(key).toMatch(/^[0-9a-f]{16}:ZA:https:\/\/api\.example\/api\/v1$/); + expect(key).not.toContain(tenantA.apiKey); + expect(productCacheKey("ZA", tenantA)).toBe(key); + expect(productCacheKey("ZA", tenantB)).not.toBe(key); + expect(productCacheKey("PT", tenantA)).not.toBe(key); + }); +}); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md index d178c1ec..66bcf733 100644 --- a/tasks/prd-drgreen-commission-flex.md +++ b/tasks/prd-drgreen-commission-flex.md @@ -15,7 +15,7 @@ ## 0. Status (2026-10-05) -BS-F01 and BS-F02 are built on the branch above with unit tests. Not yet done, and why: +BS-F01, BS-F02 and BS-F03 are built on the branch above with unit tests. Not yet done, and why: - **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`. - **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. @@ -69,12 +69,12 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): - [x] Unit tests: response-priced row; mismatch between client price and Dr Green price → Dr Green wins and the difference is logged at warn. — `tests/unit/submit-order-pricing.test.ts`, `tests/unit/order-pricing.test.ts`, `tests/unit/storefront-orders-sync-totals.test.ts`. - [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* -### BS-F03: Tenant-scoped product cache +### BS-F03: Tenant-scoped product cache — ✅ built **Acceptance Criteria:** -- [ ] `fetchProduct` cache key includes the tenant's API key id or tenant id: `${tenantKey}:${country}:${config.apiUrl}`; `invalidateProductCache()` keeps clearing everything. -- [ ] Platform-key fallback tenants (`lib/tenant/tenant-config.ts:85-90`) share the platform key's cache entry, which is correct (they get the platform key's price). -- [ ] Unit test: two configs, same country, different keys → independent entries. -- [ ] Typecheck/lint passes. +- [x] `fetchProduct` cache key includes the tenant's API key id or tenant id: `${tenantKey}:${country}:${config.apiUrl}`; `invalidateProductCache()` keeps clearing everything. — `tenantKey` is the first 16 hex of SHA-256(apiKey) (`DoctorGreenConfig` carries no tenant id, and the raw key never goes into a map key). Cache moved to `lib/drgreen/product-cache.ts`; `doctor-green-api.ts` re-exports `invalidateProductCache` so the webhook import is unchanged. +- [x] Platform-key fallback tenants (`lib/tenant/tenant-config.ts:85-90`) share the platform key's cache entry, which is correct (they get the platform key's price). — falls out of keying by the key; tested. +- [x] Unit test: two configs, same country, different keys → independent entries. — `tests/unit/product-cache-tenant-scope.test.ts`. +- [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* ### BS-F04: No discount presentation **Acceptance Criteria:** From 2c92b440bd76b0a643f16681db36df34e6a22d29 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 12:00:07 +0100 Subject: [PATCH 4/6] fix(storefront): remove the dormant percentage-off badge (BS-F04) The product page rendered -X% OFF whenever product.discount was set. Dr Green never sends it today, but Commission Flex gives holders their own price and the Flex PRD rules out any was/now, badge or percentage-off on a storefront. Badge removed with a pointer to the non-goals; discount dropped from the UI product type. No strike-through price styling exists; a source-scan test now guards app/store and the storefront components. --- .../products/[id]/product-detail-client.tsx | 18 +++----- .../products/[id]/product-detail-types.ts | 1 - .../unit/no-discount-presentation.test.ts | 46 +++++++++++++++++++ tasks/prd-drgreen-commission-flex.md | 14 +++--- 4 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 nextjs_space/tests/unit/no-discount-presentation.test.ts diff --git a/nextjs_space/app/store/[slug]/products/[id]/product-detail-client.tsx b/nextjs_space/app/store/[slug]/products/[id]/product-detail-client.tsx index 0f3b41e1..b5070db0 100644 --- a/nextjs_space/app/store/[slug]/products/[id]/product-detail-client.tsx +++ b/nextjs_space/app/store/[slug]/products/[id]/product-detail-client.tsx @@ -219,18 +219,12 @@ export function ProductDetailClient({ paddingBottom: "100%", // Square aspect ratio }} > - {/* Discount Badge */} - {product.discount && product.discount > 0 && ( -
- -{product.discount}% OFF -
- )} + {/* No discount badge, on purpose (BS-F04). Prices are shown as + they are: no was/now, badge or percentage-off on any + storefront price — Dr Green Commission Flex PRD §5 non-goals + (dr-green-backend docs/prd/commission-flex.prd.md) and + tasks/prd-drgreen-commission-flex.md FR-4. A Flex price is + the holder's price, not a discount. */} {/* Main Image - Gallery Aware */} {((product.strainImages && product.strainImages.length > 0) || imageUrl) ? ( diff --git a/nextjs_space/app/store/[slug]/products/[id]/product-detail-types.ts b/nextjs_space/app/store/[slug]/products/[id]/product-detail-types.ts index f777e127..46baa7d4 100644 --- a/nextjs_space/app/store/[slug]/products/[id]/product-detail-types.ts +++ b/nextjs_space/app/store/[slug]/products/[id]/product-detail-types.ts @@ -23,7 +23,6 @@ export interface Product { stock_quantity?: number; image_url?: string; expiryDate?: string; - discount?: number; strainImages?: Array<{ strainImageUrl?: string; altText?: string; diff --git a/nextjs_space/tests/unit/no-discount-presentation.test.ts b/nextjs_space/tests/unit/no-discount-presentation.test.ts new file mode 100644 index 00000000..574e8120 --- /dev/null +++ b/nextjs_space/tests/unit/no-discount-presentation.test.ts @@ -0,0 +1,46 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * BS-F04 / FR-4: nothing on a BudStacks storefront presents a price as a + * discount — no was/now strike-through, badge or percentage-off. A Dr Green + * Commission Flex price is the holder's price, not a reduction (Dr Green + * commission-flex PRD §5 non-goals). Source scan of every storefront surface. + */ +const root = process.cwd(); // vitest runs from nextjs_space/ +const STOREFRONT_DIRS = ["app/store", "components/sections", "components/shop", "components/storefront"]; +const STOREFRONT_FILES = ["components/cart-dropdown.tsx"]; + +function sourceFiles(dir: string): string[] { + const abs = join(root, dir); + let entries: string[]; + try { + entries = readdirSync(abs); + } catch { + return []; + } + return entries.flatMap((name) => { + const full = join(abs, name); + if (statSync(full).isDirectory()) return sourceFiles(relative(root, full)); + return /\.(tsx|ts|css)$/.test(name) ? [relative(root, full)] : []; + }); +} + +const files = [...STOREFRONT_DIRS.flatMap(sourceFiles), ...STOREFRONT_FILES]; + +describe("no discount presentation on the storefront (BS-F04)", () => { + it("scans the storefront surfaces", () => { + expect(files.length).toBeGreaterThan(10); + expect(files).toContain("app/store/[slug]/products/[id]/product-detail-client.tsx"); + }); + + it.each([ + ["a percentage-off badge", /%\s*OFF/i], + ["strike-through styling", /line-through|textDecoration:\s*["']line-through|]|| { + const offenders = files.filter((f) => pattern.test(readFileSync(join(root, f), "utf8"))); + expect(offenders).toEqual([]); + }); +}); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md index 66bcf733..c483c672 100644 --- a/tasks/prd-drgreen-commission-flex.md +++ b/tasks/prd-drgreen-commission-flex.md @@ -15,10 +15,12 @@ ## 0. Status (2026-10-05) -BS-F01, BS-F02 and BS-F03 are built on the branch above with unit tests. Not yet done, and why: +All four stories are built on the branch above, one commit per story, with unit tests (`720ab40e` BS-F01 · `8d256cc7` BS-F02 · `90ce0c16` BS-F03 · BS-F04 in the last commit). Not yet done, and why: - **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`. -- **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. +- **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. For BS-F01: put an item in the basket, change nothing, open checkout (no "Price updated"); then edit the basket's stored price in localStorage `budstack-cart` and reload checkout ("Price updated", store corrected); add a strain id that is not in the catalogue (removed with a message). For BS-F02: place a test order, compare `orders.total` with Dr Green admin's total + delivery for the same order. +- **BS-F04 needs nothing from Dr Green to ship** — removing the dormant badge is safe now; the PRD's dependency on Flex Phase A is only for verifying a Flex price end to end. +- **New outbound call per order (BS-F02):** `submitOrder` now makes one signed `GET /dapp/orders/:id` after the order is created (the create response has no lines). It is after the irreversible step and never fails the checkout; on failure the lines are priced from the server catalogue and a warn is logged. The submit route already made the same GET via `syncOrderById` straight after. ### Corrections found in code while building (v1 → v2) @@ -76,11 +78,11 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): - [x] Unit test: two configs, same country, different keys → independent entries. — `tests/unit/product-cache-tenant-scope.test.ts`. - [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* -### BS-F04: No discount presentation +### BS-F04: No discount presentation — ✅ built **Acceptance Criteria:** -- [ ] Remove the `-X% OFF` badge in `product-detail-client.tsx` (or hard-disable it); add a code comment pointing at the Dr Green Flex PRD non-goals. -- [ ] Grep the storefront for strike-through price styling (`line-through` near a price) and remove any found. -- [ ] Verify in browser; typecheck/lint passes. +- [x] Remove the `-X% OFF` badge in `product-detail-client.tsx` (or hard-disable it); add a code comment pointing at the Dr Green Flex PRD non-goals. — Removed, comment left in its place; `discount` dropped from the UI type `product-detail-types.ts` (the API type in `doctor-green-api.ts` keeps it, since Dr Green's `Strain.discount` column exists, unused). +- [x] Grep the storefront for strike-through price styling (`line-through` near a price) and remove any found. — None found. The only `line-through` uses in the app are a hidden-section label in tenant-admin (`branding/tabs/pages-tab.tsx`) and a completed-task title (`components/ui/task-card.tsx`), neither a price. No ``/``/``. `tests/unit/no-discount-presentation.test.ts` now scans `app/store`, `components/{sections,shop,storefront}` and `cart-dropdown.tsx` and fails on `% OFF`, strike-through or a `.discount` render. +- [ ] Verify in browser; typecheck/lint passes — *pending (see §0).* ## 4. Functional requirements From 167ba5cd9c7d297ac87a219ff9fb3118c7059dc8 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 12:05:41 +0100 Subject: [PATCH 5/6] fix(orders): review fixes for checkout re-price and Dr Green totals (BS-F01, BS-F02) - syncOneOrder mirrors the line-items total only while payment is PENDING: Dr Green's reader recomputes localPrice from today's price until the line-price snapshot ships, so a paid order must not move retroactively. The stored deliveryCharge is still always mirrored. - Redact the search param (customer email) from the Dr Green request log. - Show a delivery quote only when its currency matches the basket's. - A live price of 0 removes the basket line instead of showing it free. - Discard catalogue fallback prices that do not reconcile with Dr Green's total in favour of the per-gram share of that total. - getCart narrows GET /dapp/carts by the customer's email. --- .../api/store/[slug]/checkout/quote/route.ts | 11 ++- .../[slug]/checkout/use-checkout-pricing.ts | 18 ++++- nextjs_space/lib/checkout/reprice-basket.ts | 11 ++- .../lib/drgreen/drgreen-api-client.ts | 14 +++- nextjs_space/lib/drgreen/drgreen-cart.ts | 13 ++-- nextjs_space/lib/drgreen/order-pricing.ts | 70 ++++++++++++++----- nextjs_space/lib/orders/storefront-orders.ts | 17 +++-- .../unit/checkout-reprice-basket.test.ts | 6 ++ .../tests/unit/drgreen-log-redaction.test.ts | 23 ++++++ nextjs_space/tests/unit/order-pricing.test.ts | 26 ++++++- .../storefront-orders-sync-totals.test.ts | 18 ++++- tasks/prd-drgreen-commission-flex.md | 7 +- 12 files changed, 191 insertions(+), 43 deletions(-) create mode 100644 nextjs_space/tests/unit/drgreen-log-redaction.test.ts diff --git a/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts b/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts index 08ff4139..2e9a6e3c 100644 --- a/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts +++ b/nextjs_space/app/api/store/[slug]/checkout/quote/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/db"; import { getCurrentTenant } from "@/lib/tenant/tenant"; import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; import { fetchDeliveryQuote } from "@/lib/drgreen/delivery-quote"; +import { getCurrencySymbol } from "@/lib/drgreen/doctor-green-api"; import { apiError } from "@/lib/api-error"; import { parseSlug } from "@/lib/validation/parse-uuid"; import { logger } from "@/lib/logger"; @@ -11,7 +12,7 @@ import { logger } from "@/lib/logger"; export const dynamic = "force-dynamic"; const ROUTE = "GET /api/store/[slug]/checkout/quote"; -const NO_QUOTE = { deliveryCharge: null, currency: null } as const; +const NO_QUOTE = { deliveryCharge: null, currency: null, currencySymbol: null } as const; /** * GET /api/store/[slug]/checkout/quote — Dr Green's delivery charge for the @@ -55,7 +56,13 @@ export const GET = withAuth(async (_request, { user }, { slug }) => { secretKey: config.secretKey, apiUrl: config.apiUrl, }); - return NextResponse.json(quote ?? NO_QUOTE); + if (!quote) return NextResponse.json(NO_QUOTE); + // The symbol lets checkout refuse a charge quoted in a different + // currency from the basket rather than print it under the wrong one. + return NextResponse.json({ + ...quote, + currencySymbol: quote.currency ? getCurrencySymbol(quote.currency) : null, + }); } catch (quoteError) { logger.warn("[checkout-quote] Dr Green cart read failed", { tenantId: tenant.id, diff --git a/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts index e68e7708..6abea3e9 100644 --- a/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts +++ b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts @@ -37,7 +37,7 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { const [status, setStatus] = useState("loading"); const [updatedIds, setUpdatedIds] = useState>(new Set()); const [removedNames, setRemovedNames] = useState([]); - const [deliveryCharge, setDeliveryCharge] = useState(null); + const [quote, setQuote] = useState<{ charge: number; symbol: string } | null>(null); useEffect(() => { if (!signature) { @@ -91,8 +91,14 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { .then((res) => (res.ok ? res.json() : null)) .then((body) => { const value = body?.deliveryCharge; - if (!cancelled && typeof value === "number" && Number.isFinite(value)) { - setDeliveryCharge(value); + const symbol = body?.currencySymbol; + if ( + !cancelled && + typeof value === "number" && + Number.isFinite(value) && + typeof symbol === "string" + ) { + setQuote({ charge: value, symbol }); } }) .catch(() => { @@ -103,5 +109,11 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { }; }, [slug]); + // Only a charge quoted in the basket's own currency is shown and added; + // anything else falls back to "calculated by Dr Green". + const basketCurrency = items[0]?.currency; + const deliveryCharge = + quote && basketCurrency && quote.symbol === basketCurrency ? quote.charge : null; + return { status, updatedIds, removedNames, deliveryCharge }; } diff --git a/nextjs_space/lib/checkout/reprice-basket.ts b/nextjs_space/lib/checkout/reprice-basket.ts index 4e8c81c1..08c4a957 100644 --- a/nextjs_space/lib/checkout/reprice-basket.ts +++ b/nextjs_space/lib/checkout/reprice-basket.ts @@ -43,9 +43,16 @@ export function livePriceOf(product: LiveCatalogueProduct): number { return product.price || product.retailPrice || 0; } -/** Orderable now: the order-submit route drops anything else server-side. */ +/** + * Orderable now: the order-submit route drops unavailable lines server-side, + * and a price of 0 means "price unavailable", never a free product. + */ function isOrderable(product: LiveCatalogueProduct): boolean { - return product.isAvailable !== false && product.in_stock !== false; + return ( + product.isAvailable !== false && + product.in_stock !== false && + livePriceOf(product) > 0 + ); } export function repriceBasket( diff --git a/nextjs_space/lib/drgreen/drgreen-api-client.ts b/nextjs_space/lib/drgreen/drgreen-api-client.ts index 5555a225..2861aa80 100644 --- a/nextjs_space/lib/drgreen/drgreen-api-client.ts +++ b/nextjs_space/lib/drgreen/drgreen-api-client.ts @@ -221,6 +221,16 @@ export function generateDrGreenSignature(payload: string, base64PrivateKey: stri // ── API request function ── +/** Query params whose values are personal data and must not be logged. */ +const PII_QUERY_PARAMS = ['search']; + +export function redactQueryForLog(url: string): string { + return PII_QUERY_PARAMS.reduce( + (out, name) => out.replace(new RegExp(`([?&]${name}=)[^&#]*`, 'gi'), '$1'), + url, + ); +} + export async function callDrGreenAPI( endpoint: string, options: DrGreenApiOptions @@ -252,7 +262,9 @@ export async function callDrGreenAPI( ? `${baseUrl}${endpoint}?${queryString}` : `${baseUrl}${endpoint}`; - logger.info(`[DrGreen API] >>> ${method} ${fullUrl}`); + // `search` carries a customer's email (checkout delivery quote, getCart) — + // never write it to the logs. + logger.info(`[DrGreen API] >>> ${method} ${redactQueryForLog(fullUrl)}`); if (!apiKey || !secretKey) { throw new Error('MISSING_CREDENTIALS'); diff --git a/nextjs_space/lib/drgreen/drgreen-cart.ts b/nextjs_space/lib/drgreen/drgreen-cart.ts index 11253ca2..60c899c2 100644 --- a/nextjs_space/lib/drgreen/drgreen-cart.ts +++ b/nextjs_space/lib/drgreen/drgreen-cart.ts @@ -173,15 +173,20 @@ export async function getCart(params: { const clientId = await ensureClientId(userId, tenantId, apiKey, secretKey); // Refresh from Dr. Green API. - // clientId goes in the query string so (a) the backend's GetCartsDto can - // filter and (b) the signature matches — DualAuthGuard signs the query - // string for GETs and JSON.stringify(req.params) when no query is sent. + // GetCartsDto filters by `search` (name/email) only — a clientId param is + // stripped — and lists ten clients per page, so narrow by the customer's + // email to put their cart on page one. A query is always sent because + // DualAuthGuard signs the query string for GETs. + const owner = await prisma.users.findUnique({ + where: { id: userId }, + select: { email: true }, + }); const response = await callDrGreenAPI('/dapp/carts', { method: "GET", apiKey, secretKey, baseUrl: apiUrl, - queryParams: { clientId }, + queryParams: owner?.email ? { search: owner.email } : { clientId }, }); // The list is every client of this key with a non-empty cart (Dr Green diff --git a/nextjs_space/lib/drgreen/order-pricing.ts b/nextjs_space/lib/drgreen/order-pricing.ts index 9210dd71..30e22759 100644 --- a/nextjs_space/lib/drgreen/order-pricing.ts +++ b/nextjs_space/lib/drgreen/order-pricing.ts @@ -105,6 +105,12 @@ export interface OrderPricing { * Price each line: Dr Green's order line → the server-fetched live catalogue * → an even per-gram share of whatever part of Dr Green's total the priced * lines do not account for. The browser's price is never a source. + * + * Catalogue prices are a fallback, and can be FX-converted from the EUR base + * (normalizeProduct priority 3) rather than what Dr Green charged. When they + * do not reconcile with Dr Green's total, they are discarded and those lines + * take the per-gram share instead, so the lines always add up to what Dr + * Green stored whenever that is possible. */ export function priceOrderLines(params: { items: readonly OrderLineInput[]; @@ -114,18 +120,55 @@ export function priceOrderLines(params: { }): OrderPricing { const { items, drGreenUnitPrices, catalogueUnitPrices, drGreenSubtotal } = params; + const withCatalogue = priceLines(items, drGreenUnitPrices, catalogueUnitPrices, drGreenSubtotal); + const usedCatalogue = withCatalogue.lines.some((l) => l.source === "catalogue"); + const lines = + usedCatalogue && withCatalogue.disagrees + ? pickReconciling(withCatalogue, priceLines(items, drGreenUnitPrices, undefined, drGreenSubtotal)) + : withCatalogue; + + const mismatches: PriceMismatch[] = lines.lines.flatMap((line, i) => { + const clientPrice = items[i].clientPrice; + return clientPrice !== null && differs(clientPrice, line.price) + ? [{ strainId: line.strainId, clientPrice, price: line.price, source: line.source }] + : []; + }); + + return { + lines: lines.lines, + subtotal: drGreenSubtotal ?? lines.total, + mismatches, + linesDisagreeWithTotal: lines.disagrees, + }; +} + +interface LinePricing { + lines: PricedOrderLine[]; + total: number; + disagrees: boolean; +} + +function pickReconciling(preferred: LinePricing, alternative: LinePricing): LinePricing { + return alternative.disagrees ? preferred : alternative; +} + +function priceLines( + items: readonly OrderLineInput[], + drGreenUnitPrices: ReadonlyMap, + catalogueUnitPrices: Readonly> | undefined, + drGreenSubtotal: number | null, +): LinePricing { const resolved = items.map((item) => { const fromOrder = drGreenUnitPrices.get(item.strainId); if (fromOrder !== undefined) return { item, price: fromOrder, source: "drgreen" as const }; const fromCatalogue = moneyOrNull(catalogueUnitPrices?.[item.strainId]); - if (fromCatalogue !== null) return { item, price: fromCatalogue, source: "catalogue" as const }; + if (fromCatalogue !== null && fromCatalogue > 0) { + return { item, price: fromCatalogue, source: "catalogue" as const }; + } return { item, price: null, source: "allocated" as const }; }); - const knownTotal = resolved.reduce( - (sum, r) => sum + (r.price ?? 0) * r.item.quantity, - 0, - ); + const knownTotal = resolved.reduce((sum, r) => sum + (r.price ?? 0) * r.item.quantity, 0); const unknownGrams = resolved.reduce( (sum, r) => sum + (r.price === null ? r.item.quantity : 0), 0, @@ -140,22 +183,11 @@ export function priceOrderLines(params: { price: r.price ?? allocatedUnit, source: r.source, })); - - const linesTotal = lines.reduce((sum, l) => sum + l.price * l.quantity, 0); - const subtotal = drGreenSubtotal ?? linesTotal; - - const mismatches: PriceMismatch[] = lines.flatMap((line, i) => { - const clientPrice = items[i].clientPrice; - return clientPrice !== null && differs(clientPrice, line.price) - ? [{ strainId: line.strainId, clientPrice, price: line.price, source: line.source }] - : []; - }); - + const total = lines.reduce((sum, l) => sum + l.price * l.quantity, 0); return { lines, - subtotal, - mismatches, - linesDisagreeWithTotal: drGreenSubtotal !== null && differs(linesTotal, drGreenSubtotal), + total, + disagrees: drGreenSubtotal !== null && differs(total, drGreenSubtotal), }; } diff --git a/nextjs_space/lib/orders/storefront-orders.ts b/nextjs_space/lib/orders/storefront-orders.ts index 74dca262..7a353e9e 100644 --- a/nextjs_space/lib/orders/storefront-orders.ts +++ b/nextjs_space/lib/orders/storefront-orders.ts @@ -101,12 +101,23 @@ export async function syncOneOrder( const details = res?.data?.orderDetails ?? res?.orderDetails ?? res?.data ?? res ?? {}; + const pay = + typeof details?.paymentStatus === "string" + ? details.paymentStatus.toUpperCase() + : null; + // BS-F02: mirror Dr Green's totals. Reads the LOCAL line-items total // (localPrice.totalAmount — orderDetails.totalAmount is overwritten with // the USD base sum by Dr Green's reader) and the stored deliveryCharge. + // Until Dr Green's order-line price snapshot ships, the reader recomputes + // localPrice from TODAY's catalogue price, so the line-items total is only + // mirrored while the order is unpaid; once paid, a later price change must + // not rewrite what was charged. deliveryCharge is stored, so always safe. + const fromDrGreen = totalsFromOrderDetails(details); + const unpaid = (pay ?? order.paymentStatus) === "PENDING"; const totals = planTotalsUpdate( { subtotal: order.subtotal, shippingCost: order.shippingCost, total: order.total }, - totalsFromOrderDetails(details), + { ...fromDrGreen, subtotal: unpaid ? fromDrGreen.subtotal : null }, ); const data: { @@ -118,10 +129,6 @@ export async function syncOneOrder( total?: number; } = { ...totals }; - const pay = - typeof details?.paymentStatus === "string" - ? details.paymentStatus.toUpperCase() - : null; if (pay && pay !== order.paymentStatus && SYNCABLE_PAYMENT.has(pay)) { data.paymentStatus = pay; } diff --git a/nextjs_space/tests/unit/checkout-reprice-basket.test.ts b/nextjs_space/tests/unit/checkout-reprice-basket.test.ts index 614f1cf6..6e8e2371 100644 --- a/nextjs_space/tests/unit/checkout-reprice-basket.test.ts +++ b/nextjs_space/tests/unit/checkout-reprice-basket.test.ts @@ -80,6 +80,12 @@ describe("repriceBasket", () => { expect(result.removed.map((i) => i.productId)).toEqual(["s-2"]); }); + it("removes a product whose live price is unavailable (0) rather than showing it free", () => { + const result = repriceBasket([line()], [live({ price: 0, retailPrice: 0 })]); + expect(result.items).toEqual([]); + expect(result.removed.map((i) => i.productId)).toEqual(["s-1"]); + }); + it("ignores sub-cent float noise", () => { const result = repriceBasket([line({ price: 165 })], [live({ price: 165.001 })]); expect(result.changed).toBe(false); diff --git a/nextjs_space/tests/unit/drgreen-log-redaction.test.ts b/nextjs_space/tests/unit/drgreen-log-redaction.test.ts new file mode 100644 index 00000000..51a28706 --- /dev/null +++ b/nextjs_space/tests/unit/drgreen-log-redaction.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { redactQueryForLog } from "@/lib/drgreen/drgreen-api-client"; + +/** + * BS-F01: the checkout delivery quote narrows GET /dapp/carts with + * `search=`. The request URL is logged on every call, so the + * email must be redacted there. + */ +describe("redactQueryForLog", () => { + it("redacts the search value wherever it sits in the query", () => { + expect(redactQueryForLog("https://x/api/v1/dapp/carts?search=ann%40example.com")).toBe( + "https://x/api/v1/dapp/carts?search=", + ); + expect(redactQueryForLog("https://x/a?take=10&search=ann%40example.com&page=1")).toBe( + "https://x/a?take=10&search=&page=1", + ); + }); + + it("leaves other URLs alone", () => { + const url = "https://x/api/v1/dapp/strains?countryCode=ZAF&orderBy=desc&take=100&page=1"; + expect(redactQueryForLog(url)).toBe(url); + }); +}); diff --git a/nextjs_space/tests/unit/order-pricing.test.ts b/nextjs_space/tests/unit/order-pricing.test.ts index 8b2de210..dafc2989 100644 --- a/nextjs_space/tests/unit/order-pricing.test.ts +++ b/nextjs_space/tests/unit/order-pricing.test.ts @@ -114,7 +114,8 @@ describe("priceOrderLines", () => { expect(result.linesDisagreeWithTotal).toBe(false); }); - it("keeps Dr Green's subtotal and flags lines that do not add up to it", () => { + it("discards catalogue prices that do not reconcile with Dr Green's total", () => { + // e.g. an FX-converted catalogue price instead of the location price. const result = priceOrderLines({ items: [input("s-1", 5)], drGreenUnitPrices: new Map(), @@ -122,10 +123,31 @@ describe("priceOrderLines", () => { drGreenSubtotal: 825, }); expect(result.subtotal).toBe(825); - expect(result.lines[0].price).toBe(160); + expect(result.lines[0]).toMatchObject({ price: 165, source: "allocated" }); + expect(result.linesDisagreeWithTotal).toBe(false); + }); + + it("keeps Dr Green's subtotal and flags lines that cannot add up to it", () => { + const result = priceOrderLines({ + items: [input("s-1", 5)], + drGreenUnitPrices: new Map([["s-1", 200]]), + drGreenSubtotal: 825, + }); + expect(result.subtotal).toBe(825); + expect(result.lines[0].price).toBe(200); expect(result.linesDisagreeWithTotal).toBe(true); }); + it("never uses a zero catalogue price", () => { + const result = priceOrderLines({ + items: [input("s-1", 5)], + drGreenUnitPrices: new Map(), + catalogueUnitPrices: { "s-1": 0 }, + drGreenSubtotal: 825, + }); + expect(result.lines[0]).toMatchObject({ price: 165, source: "allocated" }); + }); + it("uses the lines' sum when Dr Green gave no total (older backend)", () => { const result = priceOrderLines({ items: [input("s-1", 5, 999)], diff --git a/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts b/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts index 36656a33..aab9de08 100644 --- a/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts +++ b/nextjs_space/tests/unit/storefront-orders-sync-totals.test.ts @@ -76,15 +76,29 @@ describe("syncOneOrder — totals", () => { }); }); - it("syncs totals alongside a status change in one write", async () => { + it("on a paid order mirrors only the stored delivery, not the recomputed line total", async () => { apiMock.callDrGreenAPI.mockResolvedValue(details({ paymentStatus: "PAID" })); await syncOneOrder(row, CONFIG); expect(prismaMock.orders.update).toHaveBeenCalledWith({ where: { id: "order-1" }, - data: { subtotal: 1185, shippingCost: 110, total: 1295, paymentStatus: "PAID" }, + data: { shippingCost: 110, total: 1110, paymentStatus: "PAID" }, }); }); + // Until Dr Green's line-price snapshot ships, localPrice.totalAmount is + // recomputed from today's price; a price move after payment must not + // rewrite what was charged. + it("does not rewrite a paid order's total when the catalogue price has since moved", async () => { + apiMock.callDrGreenAPI.mockResolvedValue( + details({ paymentStatus: "PAID", localPrice: { currency: "ZAR", totalAmount: 1050 } }), + ); + await syncOneOrder( + { ...row, paymentStatus: "PAID", subtotal: 1185, shippingCost: 110, total: 1295 }, + CONFIG, + ); + expect(prismaMock.orders.update).not.toHaveBeenCalled(); + }); + it("never throws when Dr Green is unreachable", async () => { apiMock.callDrGreenAPI.mockRejectedValue(new Error("502")); await expect(syncOneOrder(row, CONFIG)).resolves.toBeUndefined(); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md index c483c672..e552bdf6 100644 --- a/tasks/prd-drgreen-commission-flex.md +++ b/tasks/prd-drgreen-commission-flex.md @@ -15,11 +15,12 @@ ## 0. Status (2026-10-05) -All four stories are built on the branch above, one commit per story, with unit tests (`720ab40e` BS-F01 · `8d256cc7` BS-F02 · `90ce0c16` BS-F03 · BS-F04 in the last commit). Not yet done, and why: +All four stories are built on the branch above, one commit per story, with unit tests (`720ab40e` BS-F01 · `8d256cc7` BS-F02 · `90ce0c16` BS-F03 · `2c92b440` BS-F04 · review fixes in the commit after). Not yet done, and why: - **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`. - **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. For BS-F01: put an item in the basket, change nothing, open checkout (no "Price updated"); then edit the basket's stored price in localStorage `budstack-cart` and reload checkout ("Price updated", store corrected); add a strain id that is not in the catalogue (removed with a message). For BS-F02: place a test order, compare `orders.total` with Dr Green admin's total + delivery for the same order. - **BS-F04 needs nothing from Dr Green to ship** — removing the dormant badge is safe now; the PRD's dependency on Flex Phase A is only for verifying a Flex price end to end. +- **Review fixes (code-reviewer pass, 2026-10-05), in the fifth commit:** subtotal mirror gated to unpaid orders (above); the customer email sent as `search` to `GET /dapp/carts` is redacted from the `[DrGreen API] >>>` request log (`redactQueryForLog`); a delivery quote is shown only when its currency symbol matches the basket's (else "Calculated by Dr Green"); a live price of 0 ("price unavailable") removes the line instead of showing it free; catalogue fallback prices that do not reconcile with Dr Green's total are discarded for the per-gram share of that total (they can be FX-converted, not what Dr Green charged); `getCart` narrows by `search=` so the customer's cart is on page one. - **New outbound call per order (BS-F02):** `submitOrder` now makes one signed `GET /dapp/orders/:id` after the order is created (the create response has no lines). It is after the irreversible step and never fails the checkout; on failure the lines are priced from the server catalogue and a warn is logged. The submit route already made the same GET via `syncOrderById` straight after. ### Corrections found in code while building (v1 → v2) @@ -27,7 +28,7 @@ All four stories are built on the branch above, one commit per story, with unit 1. **GET `/dapp/carts` ignores `clientId`.** `GetCartsDto` has only `search` (plus pagination), the whitelist strips the rest, and the list is every client of the key with a non-empty cart, newest first, ten per page (`dr-green-backend src/carts/carts.service.ts getCartList`). `getCart` read `data.clients[0]` — whichever customer of the store touched a cart last — and wrote that cart into the signed-in user's `drgreen_carts` mirror. Fixed with `pickClientCart(response, clientId)` (`lib/drgreen/delivery-quote.ts`), used by `getCart` and the new quote. 2. **There was no "existing checkout quote path".** Nothing in BudStacks read `localPrices.deliveryCharge`, and the catalogue (`/dapp/strains`) does not carry the delivery charge. Added `GET /api/store/[slug]/checkout/quote` (signed `GET /dapp/carts?search=`, picked by client id). **Expect "Calculated by Dr Green" on most checkouts:** BudStacks keeps the basket in the browser and only pushes it to Dr Green at submit, and Dr Green empties the server cart when an order is created, so the customer usually has no server cart to quote from. Showing the real charge every time needs either a Dr Green delivery-quote endpoint (e.g. `deliveryCharge` on the `/dapp/strains` location select) or pushing the basket to Dr Green's cart at checkout load — see §8. 3. **`orderDetails.totalAmount` from `GET /dapp/orders/:id` is not the stored order total.** `getOrderById` selects `totalAmount` and then overwrites it with Σ `strain.retailPrice × quantity` — the strain **base** (USD) price — and sets `deliveryFee` to the constant. Mirroring it as the PRD said would write a USD figure into a rand column. The local-currency total is `orderDetails.localPrice.totalAmount`; `orderDetails.deliveryCharge` is the stored, locked value. The create response (`POST /dapp/orders`) is the raw Order row, so its `totalAmount` is the stored local figure — that is what `submitOrder` uses. -4. **Until Dr Green's order-line price snapshot ships (`order-line-price-snapshot.prd.md` US-P01..P03), the GET reader prices lines and `localPrice.totalAmount` from the strain's CURRENT price** (matched on the client's current shipping country). At creation time this equals what Dr Green stored, which is when `submitOrder` and the post-mint `syncOrderById` read it. A later `syncOneOrder` (customer opens orders/dashboard) on a still-unsettled order would follow a price change made after the order was placed. Commission Flex depends on P01..P03 landing first, so this closes before any per-KEY price exists; between now and then it only matters if Dr Green changes a list price while an order is unpaid. Historic rows (priced from the browser) on non-terminal orders are corrected by the same sync. +4. **Until Dr Green's order-line price snapshot ships (`order-line-price-snapshot.prd.md` US-P01..P03), the GET reader prices lines and `localPrice.totalAmount` from the strain's CURRENT price** (matched on the client's current shipping country). At creation time this equals what Dr Green stored, which is when `submitOrder` and the post-mint `syncOrderById` read it. A later `syncOneOrder` would follow a price change made after the order was placed, and `listUserOrdersWithSync` syncs every order not settled on both axes — including PAID orders still in fulfilment. So the subtotal is mirrored only while payment is `PENDING`; a paid order's total can no longer move retroactively (tested). Residual: an unpaid order can still follow a list-price change made before it is paid. Commission Flex depends on P01..P03 landing first, after which the reader returns the snapshot and the gate could be lifted. Historic browser-priced rows are corrected by the sync while unpaid; paid historic rows only get the stored delivery charge. ## 1. Introduction / Overview @@ -66,7 +67,7 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): **Acceptance Criteria:** - [x] `submitOrder` (`lib/drgreen/drgreen-orders.ts`) prices `order_items.price` and `orders.subtotal/total` from Dr Green's order response (`totalAmount`, `deliveryCharge`, and per-line `localPrice.productAmount` from `GET /dapp/orders/:id` when the create response lacks lines), never from the request body. The client-sent `strain.retailPrice` is ignored for pricing (kept only for the product name fallback and the warn log). — `lib/drgreen/order-pricing.ts` `resolveOrderPricing`. The create response carries no lines today, so every order makes the extra signed GET. `productAmount` is the **line** total, so `order_items.price` (per gram, as analytics expects) is `productAmount ÷ quantity`. If the GET fails, a line is priced from the server-fetched live catalogue the submit route already loads (`catalogueUnitPrices`), then from the part of Dr Green's total the priced lines do not cover; `orders.subtotal` is always Dr Green's `totalAmount` when it sent one. The browser's price is never a source. -- [x] `syncOneOrder` also mirrors ~~`totalAmount`~~ the local line-items total and `deliveryCharge` into `subtotal`, `shippingCost`, `total` when they differ (Dr Green is the source of truth). — Reads `orderDetails.localPrice.totalAmount`, **not** `orderDetails.totalAmount` (see §0 correction 3). A field Dr Green does not report (null `deliveryCharge` on orders placed before it was locked) keeps the local value. +- [x] `syncOneOrder` also mirrors ~~`totalAmount`~~ the local line-items total and `deliveryCharge` into `subtotal`, `shippingCost`, `total` when they differ (Dr Green is the source of truth). — Reads `orderDetails.localPrice.totalAmount`, **not** `orderDetails.totalAmount` (see §0 correction 3). A field Dr Green does not report (null `deliveryCharge` on orders placed before it was locked) keeps the local value. **The line-items total is mirrored only while the order is unpaid** (Dr Green's payment status, else the row's, is `PENDING`); `deliveryCharge` (stored) is always mirrored. Reason in correction 4. - [x] Analytics revenue (`app/api/tenant-admin/analytics/route.ts`) needs no change once rows are correct; confirm with a test fixture. — It sums `orders.total` and `order_items.price × quantity`; `tests/unit/submit-order-pricing.test.ts` asserts both equal Dr Green's figures for a priced row. - [x] Unit tests: response-priced row; mismatch between client price and Dr Green price → Dr Green wins and the difference is logged at warn. — `tests/unit/submit-order-pricing.test.ts`, `tests/unit/order-pricing.test.ts`, `tests/unit/storefront-orders-sync-totals.test.ts`. - [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* From de0578082178e5ce44cef87c846e072c60324021 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 13:21:11 +0100 Subject: [PATCH 6/6] feat(checkout): show Dr Green's delivery charge from the catalogue (BS-F01) normalizeProduct keeps strainLocations[].location.deliveryCharge (new on /dapp/strains with the Dr Green delivery hotfix) as deliveryCharge + deliveryCurrency. null means the market has none set, so Dr Green bills its default 6 in the market currency and that is what is shown; an absent field (older backend) means unknown. Checkout shows the catalogue charge when it is in the basket's currency, else the server-cart quote (unchanged, now the fallback), else "Calculated by Dr Green". Pure selection in lib/checkout/delivery-charge.ts with unit tests. --- .../[slug]/checkout/use-checkout-pricing.ts | 33 +++- nextjs_space/lib/checkout/delivery-charge.ts | 60 +++++++ nextjs_space/lib/checkout/reprice-basket.ts | 4 + nextjs_space/lib/drgreen/delivery.ts | 30 ++++ nextjs_space/lib/drgreen/doctor-green-api.ts | 19 +++ .../unit/checkout-delivery-charge.test.ts | 157 ++++++++++++++++++ tasks/prd-drgreen-commission-flex.md | 9 +- 7 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 nextjs_space/lib/checkout/delivery-charge.ts create mode 100644 nextjs_space/tests/unit/checkout-delivery-charge.test.ts diff --git a/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts index 6abea3e9..cbcb4b48 100644 --- a/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts +++ b/nextjs_space/app/store/[slug]/checkout/use-checkout-pricing.ts @@ -7,6 +7,11 @@ import { repriceBasket, type LiveCatalogueProduct, } from "@/lib/checkout/reprice-basket"; +import { + catalogueDeliveryQuote, + deliveryChargeForBasket, + type DeliveryChargeQuote, +} from "@/lib/checkout/delivery-charge"; export type PricingStatus = "loading" | "live" | "unconfirmed"; @@ -26,8 +31,11 @@ export interface CheckoutPricing { * * Fetches the tenant's catalogue on load and whenever WHAT is in the basket * changes (not when only a price is written back, see basketSignature), writes - * live prices into the basket store and drops lines no longer listed. The - * delivery quote is read once: it is per market, not per basket. + * live prices into the basket store and drops lines no longer listed. + * + * Delivery: the catalogue's per-market charge (location.deliveryCharge on + * /dapp/strains) comes with every re-price; the customer's server-cart quote + * is read once as a fallback for a catalogue that does not carry it. */ export function useCheckoutPricing(slug: string): CheckoutPricing { const items = useCartStore((s) => s.items); @@ -37,7 +45,8 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { const [status, setStatus] = useState("loading"); const [updatedIds, setUpdatedIds] = useState>(new Set()); const [removedNames, setRemovedNames] = useState([]); - const [quote, setQuote] = useState<{ charge: number; symbol: string } | null>(null); + const [catalogueQuote, setCatalogueQuote] = useState(null); + const [quote, setQuote] = useState(null); useEffect(() => { if (!signature) { @@ -61,6 +70,7 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { // while the request was in flight is never overwritten. const result = repriceBasket(useCartStore.getState().items, catalogue); if (result.changed) replaceItems(result.items); + setCatalogueQuote(catalogueDeliveryQuote(result.items, catalogue)); if (result.priceChanged.length > 0) { setUpdatedIds( (prev) => new Set([...Array.from(prev), ...result.priceChanged]), @@ -77,7 +87,10 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { .catch(() => { // The order is still priced by Dr Green server-side; say we could not // confirm the price here rather than block the customer. - if (!cancelled) setStatus("unconfirmed"); + if (!cancelled) { + setStatus("unconfirmed"); + setCatalogueQuote(null); + } }); return () => { @@ -109,11 +122,13 @@ export function useCheckoutPricing(slug: string): CheckoutPricing { }; }, [slug]); - // Only a charge quoted in the basket's own currency is shown and added; - // anything else falls back to "calculated by Dr Green". - const basketCurrency = items[0]?.currency; - const deliveryCharge = - quote && basketCurrency && quote.symbol === basketCurrency ? quote.charge : null; + // Catalogue first, server cart as the fallback. Only a charge quoted in the + // basket's own currency is shown and added; anything else falls back to + // "calculated by Dr Green". + const deliveryCharge = deliveryChargeForBasket(items[0]?.currency, [ + catalogueQuote, + quote, + ]); return { status, updatedIds, removedNames, deliveryCharge }; } diff --git a/nextjs_space/lib/checkout/delivery-charge.ts b/nextjs_space/lib/checkout/delivery-charge.ts new file mode 100644 index 00000000..5863afa9 --- /dev/null +++ b/nextjs_space/lib/checkout/delivery-charge.ts @@ -0,0 +1,60 @@ +/** + * Which delivery charge checkout shows (BS-F01). Pure and client-safe. + * + * Dr Green bills a per-market delivery charge on top of the line items. Two + * sources can tell checkout what it is: + * 1. the catalogue — `deliveryCharge` on each product, from the market's + * location row on GET /dapp/strains (normalizeProduct); + * 2. the customer's Dr Green server cart (GET /api/store/[slug]/checkout/quote), + * which only exists while that cart holds items — the fallback. + * A charge is shown only when quoted in the basket's own currency; otherwise + * checkout keeps "Calculated by Dr Green". + */ +import type { CartItem } from "@/lib/cart-store"; +import type { LiveCatalogueProduct } from "@/lib/checkout/reprice-basket"; + +export interface DeliveryChargeQuote { + charge: number; + /** Display symbol of the charge's currency, compared to the basket's. */ + symbol: string; +} + +function isCharge(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** + * The catalogue's delivery charge for the basket, or null when any basket + * line's product does not carry one or two lines disagree (they are one + * market, so a disagreement means the data cannot be trusted). + */ +export function catalogueDeliveryQuote( + items: readonly CartItem[], + catalogue: readonly LiveCatalogueProduct[], +): DeliveryChargeQuote | null { + const byId = new Map(catalogue.map((p) => [p.id, p])); + let quote: DeliveryChargeQuote | null = null; + for (const item of items) { + const product = byId.get(item.productId); + const charge = product?.deliveryCharge; + const symbol = product?.deliveryCurrency; + if (!isCharge(charge) || typeof symbol !== "string" || !symbol) return null; + if (quote && (quote.charge !== charge || quote.symbol !== symbol)) return null; + quote = { charge, symbol }; + } + return quote; +} + +/** + * The first quote (in priority order) in the basket's currency, else null. + */ +export function deliveryChargeForBasket( + basketCurrency: string | undefined, + quotes: ReadonlyArray, +): number | null { + if (!basketCurrency) return null; + const match = quotes.find( + (q) => q !== null && isCharge(q.charge) && q.symbol === basketCurrency, + ); + return match ? match.charge : null; +} diff --git a/nextjs_space/lib/checkout/reprice-basket.ts b/nextjs_space/lib/checkout/reprice-basket.ts index 08c4a957..1d18dc91 100644 --- a/nextjs_space/lib/checkout/reprice-basket.ts +++ b/nextjs_space/lib/checkout/reprice-basket.ts @@ -18,6 +18,10 @@ export interface LiveCatalogueProduct { currency?: string; isAvailable?: boolean; in_stock?: boolean; + /** Dr Green's delivery charge for the market (normalizeProduct). */ + deliveryCharge?: number | null; + /** Display symbol of the delivery charge's currency. */ + deliveryCurrency?: string | null; } export interface RepriceResult { diff --git a/nextjs_space/lib/drgreen/delivery.ts b/nextjs_space/lib/drgreen/delivery.ts index 66aec0bc..3fa1ebb4 100644 --- a/nextjs_space/lib/drgreen/delivery.ts +++ b/nextjs_space/lib/drgreen/delivery.ts @@ -23,6 +23,36 @@ */ export const FALLBACK_DELIVERY_CHARGE = 5.0; +/** + * What Dr Green bills, in the order's own currency, when the market has no + * Location.deliveryCharge set. Mirrors dr-green-backend + * `CONSTANT.DELIVERY_CHARGE` (src/constants/constant.ts), which createOrder + * falls back to (`checkCartValidity`). Not a BudStacks choice: it is shown + * only because it is what the customer's card will be charged. + */ +export const DR_GREEN_DEFAULT_DELIVERY_CHARGE = 6; + +/** + * Dr Green's delivery charge for a market, read off a catalogue location + * (`strainLocations[].location` on GET /dapp/strains). + * + * - a number ≥ 0 → that charge, in the location's currency; + * - `null` → the market has none set, so Dr Green bills the default; + * - field absent (backend without the field) or unusable → null: unknown, and + * checkout falls back to the server-cart quote or "Calculated by Dr Green". + */ +export function deliveryChargeFromLocation(location: unknown): number | null { + if (!location || typeof location !== "object") return null; + if (!("deliveryCharge" in location)) return null; + const raw = (location as { deliveryCharge?: unknown }).deliveryCharge; + if (raw === null) return DR_GREEN_DEFAULT_DELIVERY_CHARGE; + const value = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + return value; +} + /** * Read the authoritative delivery charge off a Dr Green order-create response. * diff --git a/nextjs_space/lib/drgreen/doctor-green-api.ts b/nextjs_space/lib/drgreen/doctor-green-api.ts index e363d68a..d4e88b00 100644 --- a/nextjs_space/lib/drgreen/doctor-green-api.ts +++ b/nextjs_space/lib/drgreen/doctor-green-api.ts @@ -5,6 +5,7 @@ import { callDrGreenAPI } from '@/lib/drgreen/drgreen-api-client'; import { convertFromEUR } from '@/lib/exchange-rates'; +import { deliveryChargeFromLocation } from '@/lib/drgreen/delivery'; import { getCachedProducts, productCacheKey, @@ -169,6 +170,13 @@ export interface DoctorGreenProduct { }>; expiryDate?: string; discount?: number; + /** + * Dr Green's delivery charge for this market (BS-F01), from the priced + * location's `deliveryCharge`; null when the catalogue does not carry it. + */ + deliveryCharge?: number | null; + /** Display symbol of `deliveryCharge`'s currency (the location's). */ + deliveryCurrency?: string | null; strainImages?: Array<{ strainImageUrl?: string; altText?: string; @@ -302,6 +310,15 @@ async function normalizeProduct(product: DoctorGreenProduct, country: string): P const currency = getCurrencySymbol(currencyCode); + // BS-F01: the market's delivery charge, in the location's own currency. The + // backend filters strainLocations to the requested country, so loc0 is the + // market row the price above came from. + const deliveryCharge = deliveryChargeFromLocation(loc0?.location); + const deliveryCurrency = + deliveryCharge !== null && typeof loc0?.location?.currency === "string" && loc0.location.currency + ? getCurrencySymbol(loc0.location.currency) + : null; + // Resolve strainImages URLs too const resolvedStrainImages = product.strainImages?.map((img) => ({ ...img, @@ -316,6 +333,8 @@ async function normalizeProduct(product: DoctorGreenProduct, country: string): P price, currency, currencyCode, + deliveryCharge: deliveryCurrency ? deliveryCharge : null, + deliveryCurrency, in_stock: isAvailable && totalStock > 0, isAvailable: isAvailable && totalStock > 0, stock_quantity: totalStock, diff --git a/nextjs_space/tests/unit/checkout-delivery-charge.test.ts b/nextjs_space/tests/unit/checkout-delivery-charge.test.ts new file mode 100644 index 00000000..1232e5d5 --- /dev/null +++ b/nextjs_space/tests/unit/checkout-delivery-charge.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: vi.fn() })); +vi.mock("@/lib/exchange-rates", () => ({ + convertFromEUR: vi.fn(async (value: number) => value), +})); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { fetchProducts, invalidateProductCache } from "@/lib/drgreen/doctor-green-api"; +import { + DR_GREEN_DEFAULT_DELIVERY_CHARGE, + deliveryChargeFromLocation, +} from "@/lib/drgreen/delivery"; +import { + catalogueDeliveryQuote, + deliveryChargeForBasket, +} from "@/lib/checkout/delivery-charge"; +import type { CartItem } from "@/lib/cart-store"; + +/** + * BS-F01: checkout shows Dr Green's delivery charge from the catalogue + * (strainLocations[].location.deliveryCharge on /dapp/strains), falling back + * to the customer's server-cart quote, then to "Calculated by Dr Green". + */ +const config = { apiKey: "k", secretKey: "s", apiUrl: "https://stage/api/v1" }; + +const strain = (id: string, location: Record | undefined) => ({ + id, + name: id, + description: "", + thc: 20, + cbd: 1, + type: "Indica", + retailPrice: 10, + isActive: true, + strainLocations: [ + { isActive: true, isAvailable: true, stockQuantity: 5, retailPrice: 165, location }, + ], +}); + +const item = (productId: string, currency = "R"): CartItem => ({ + id: productId, + productId, + name: productId, + price: 165, + quantity: 5, + currency, +}); + +describe("deliveryChargeFromLocation", () => { + it("reads the market's charge", () => { + expect(deliveryChargeFromLocation({ currency: "ZAR", deliveryCharge: 110 })).toBe(110); + expect(deliveryChargeFromLocation({ deliveryCharge: 0 })).toBe(0); + expect(deliveryChargeFromLocation({ deliveryCharge: "110.5" })).toBe(110.5); + }); + + it("null means the market has none set, so Dr Green bills its default (6)", () => { + expect(deliveryChargeFromLocation({ currency: "ZAR", deliveryCharge: null })).toBe( + DR_GREEN_DEFAULT_DELIVERY_CHARGE, + ); + expect(DR_GREEN_DEFAULT_DELIVERY_CHARGE).toBe(6); + }); + + it("is unknown when the field is absent (older backend) or unusable", () => { + expect(deliveryChargeFromLocation({ currency: "ZAR" })).toBeNull(); + expect(deliveryChargeFromLocation(undefined)).toBeNull(); + expect(deliveryChargeFromLocation({ deliveryCharge: undefined })).toBeNull(); + expect(deliveryChargeFromLocation({ deliveryCharge: -1 })).toBeNull(); + expect(deliveryChargeFromLocation({ deliveryCharge: "" })).toBeNull(); + expect(deliveryChargeFromLocation({ deliveryCharge: "abc" })).toBeNull(); + }); +}); + +describe("fetchProducts keeps the location's delivery charge", () => { + beforeEach(() => { + vi.clearAllMocks(); + invalidateProductCache(); + }); + + it("normalises deliveryCharge + its currency symbol per product", async () => { + (callDrGreenAPI as any).mockResolvedValue({ + data: { + strains: [ + strain("set", { currency: "ZAR", countryCode: "ZAF", deliveryCharge: 110 }), + strain("unset", { currency: "ZAR", countryCode: "ZAF", deliveryCharge: null }), + strain("old-backend", { currency: "ZAR", countryCode: "ZAF" }), + ], + }, + }); + const products = await fetchProducts("ZA", config); + const byId = Object.fromEntries(products.map((p) => [p.id, p])); + expect(byId.set.deliveryCharge).toBe(110); + expect(byId.set.deliveryCurrency).toBe("R"); + expect(byId.unset.deliveryCharge).toBe(6); + expect(byId.unset.deliveryCurrency).toBe("R"); + expect(byId["old-backend"].deliveryCharge).toBeNull(); + expect(byId["old-backend"].deliveryCurrency).toBeNull(); + }); + + it("carries no charge when the location has no currency", async () => { + (callDrGreenAPI as any).mockResolvedValue({ + data: { strains: [strain("no-currency", { deliveryCharge: 110 })] }, + }); + const [product] = await fetchProducts("ZA", config); + expect(product.deliveryCharge).toBeNull(); + expect(product.deliveryCurrency).toBeNull(); + }); +}); + +describe("catalogueDeliveryQuote", () => { + const catalogue = [ + { id: "a", price: 165, deliveryCharge: 110, deliveryCurrency: "R" }, + { id: "b", price: 150, deliveryCharge: 110, deliveryCurrency: "R" }, + { id: "c", price: 150, deliveryCharge: null, deliveryCurrency: null }, + { id: "d", price: 150, deliveryCharge: 60, deliveryCurrency: "R" }, + ]; + + it("returns the market charge for the basket", () => { + expect(catalogueDeliveryQuote([item("a"), item("b")], catalogue)).toEqual({ + charge: 110, + symbol: "R", + }); + }); + + it("is null when a line's product carries no charge", () => { + expect(catalogueDeliveryQuote([item("a"), item("c")], catalogue)).toBeNull(); + expect(catalogueDeliveryQuote([item("missing")], catalogue)).toBeNull(); + }); + + it("is null when lines disagree, and for an empty basket", () => { + expect(catalogueDeliveryQuote([item("a"), item("d")], catalogue)).toBeNull(); + expect(catalogueDeliveryQuote([], catalogue)).toBeNull(); + }); +}); + +describe("deliveryChargeForBasket", () => { + const catalogueQuote = { charge: 110, symbol: "R" }; + const cartQuote = { charge: 95, symbol: "R" }; + + it("prefers the catalogue, falls back to the server cart", () => { + expect(deliveryChargeForBasket("R", [catalogueQuote, cartQuote])).toBe(110); + expect(deliveryChargeForBasket("R", [null, cartQuote])).toBe(95); + }); + + it("only shows a charge quoted in the basket's currency", () => { + expect(deliveryChargeForBasket("R", [{ charge: 6, symbol: "$" }, cartQuote])).toBe(95); + expect(deliveryChargeForBasket("€", [catalogueQuote, cartQuote])).toBeNull(); + }); + + it("is null (\"Calculated by Dr Green\") with no quote or no basket currency", () => { + expect(deliveryChargeForBasket("R", [null, null])).toBeNull(); + expect(deliveryChargeForBasket(undefined, [catalogueQuote])).toBeNull(); + }); +}); diff --git a/tasks/prd-drgreen-commission-flex.md b/tasks/prd-drgreen-commission-flex.md index e552bdf6..c717cacf 100644 --- a/tasks/prd-drgreen-commission-flex.md +++ b/tasks/prd-drgreen-commission-flex.md @@ -21,12 +21,13 @@ All four stories are built on the branch above, one commit per story, with unit - **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account. For BS-F01: put an item in the basket, change nothing, open checkout (no "Price updated"); then edit the basket's stored price in localStorage `budstack-cart` and reload checkout ("Price updated", store corrected); add a strain id that is not in the catalogue (removed with a message). For BS-F02: place a test order, compare `orders.total` with Dr Green admin's total + delivery for the same order. - **BS-F04 needs nothing from Dr Green to ship** — removing the dormant badge is safe now; the PRD's dependency on Flex Phase A is only for verifying a Flex price end to end. - **Review fixes (code-reviewer pass, 2026-10-05), in the fifth commit:** subtotal mirror gated to unpaid orders (above); the customer email sent as `search` to `GET /dapp/carts` is redacted from the `[DrGreen API] >>>` request log (`redactQueryForLog`); a delivery quote is shown only when its currency symbol matches the basket's (else "Calculated by Dr Green"); a live price of 0 ("price unavailable") removes the line instead of showing it free; catalogue fallback prices that do not reconcile with Dr Green's total are discarded for the per-gram share of that total (they can be FX-converted, not what Dr Green charged); `getCart` narrows by `search=` so the customer's cart is on page one. +- **Delivery charge from the catalogue (BS-F01 follow-up, sixth commit).** The Dr Green hotfix (`dr-green-backend` branch `fix/delivery-charge-cart-scope`, `a235e4a`) adds `deliveryCharge` to `strainLocations[].location` on `/dapp/strains`. `normalizeProduct` now keeps it as `deliveryCharge` + `deliveryCurrency` (symbol of the location's currency): a number is the market's charge; `null` (market has none) is shown as Dr Green's default 6 in the market currency, because that is what `createOrder` bills (`CONSTANT.DELIVERY_CHARGE`); a missing field (backend without the hotfix) means unknown. Checkout shows the catalogue charge when its currency matches the basket's, else the server-cart quote (kept as the fallback), else "Calculated by Dr Green" — `lib/checkout/delivery-charge.ts`, test `tests/unit/checkout-delivery-charge.test.ts`. **Until the hotfix is on the Dr Green environment a tenant points at, checkout behaves exactly as before.** Note: the null→6 case shows e.g. R6 in South Africa — correct to what is billed, and a prompt for Dr Green to set `Location.deliveryCharge` per market. - **New outbound call per order (BS-F02):** `submitOrder` now makes one signed `GET /dapp/orders/:id` after the order is created (the create response has no lines). It is after the irreversible step and never fails the checkout; on failure the lines are priced from the server catalogue and a warn is logged. The submit route already made the same GET via `syncOrderById` straight after. ### Corrections found in code while building (v1 → v2) 1. **GET `/dapp/carts` ignores `clientId`.** `GetCartsDto` has only `search` (plus pagination), the whitelist strips the rest, and the list is every client of the key with a non-empty cart, newest first, ten per page (`dr-green-backend src/carts/carts.service.ts getCartList`). `getCart` read `data.clients[0]` — whichever customer of the store touched a cart last — and wrote that cart into the signed-in user's `drgreen_carts` mirror. Fixed with `pickClientCart(response, clientId)` (`lib/drgreen/delivery-quote.ts`), used by `getCart` and the new quote. -2. **There was no "existing checkout quote path".** Nothing in BudStacks read `localPrices.deliveryCharge`, and the catalogue (`/dapp/strains`) does not carry the delivery charge. Added `GET /api/store/[slug]/checkout/quote` (signed `GET /dapp/carts?search=`, picked by client id). **Expect "Calculated by Dr Green" on most checkouts:** BudStacks keeps the basket in the browser and only pushes it to Dr Green at submit, and Dr Green empties the server cart when an order is created, so the customer usually has no server cart to quote from. Showing the real charge every time needs either a Dr Green delivery-quote endpoint (e.g. `deliveryCharge` on the `/dapp/strains` location select) or pushing the basket to Dr Green's cart at checkout load — see §8. +2. **There was no "existing checkout quote path".** Nothing in BudStacks read `localPrices.deliveryCharge`, and the catalogue (`/dapp/strains`) does not carry the delivery charge. Added `GET /api/store/[slug]/checkout/quote` (signed `GET /dapp/carts?search=`, picked by client id). **Expect "Calculated by Dr Green" on most checkouts:** BudStacks keeps the basket in the browser and only pushes it to Dr Green at submit, and Dr Green empties the server cart when an order is created, so the customer usually has no server cart to quote from. Showing the real charge every time needs either a Dr Green delivery-quote endpoint (e.g. `deliveryCharge` on the `/dapp/strains` location select) or pushing the basket to Dr Green's cart at checkout load — see §8. *Resolved by option (a): the catalogue now carries it and checkout reads it first (§0, sixth commit); the server-cart quote stays as the fallback.* 3. **`orderDetails.totalAmount` from `GET /dapp/orders/:id` is not the stored order total.** `getOrderById` selects `totalAmount` and then overwrites it with Σ `strain.retailPrice × quantity` — the strain **base** (USD) price — and sets `deliveryFee` to the constant. Mirroring it as the PRD said would write a USD figure into a rand column. The local-currency total is `orderDetails.localPrice.totalAmount`; `orderDetails.deliveryCharge` is the stored, locked value. The create response (`POST /dapp/orders`) is the raw Order row, so its `totalAmount` is the stored local figure — that is what `submitOrder` uses. 4. **Until Dr Green's order-line price snapshot ships (`order-line-price-snapshot.prd.md` US-P01..P03), the GET reader prices lines and `localPrice.totalAmount` from the strain's CURRENT price** (matched on the client's current shipping country). At creation time this equals what Dr Green stored, which is when `submitOrder` and the post-mint `syncOrderById` read it. A later `syncOneOrder` would follow a price change made after the order was placed, and `listUserOrdersWithSync` syncs every order not settled on both axes — including PAID orders still in fulfilment. So the subtotal is mirrored only while payment is `PENDING`; a paid order's total can no longer move retroactively (tested). Residual: an unpaid order can still follow a list-price change made before it is paid. Commission Flex depends on P01..P03 landing first, after which the reader returns the snapshot and the gate could be lifted. Historic browser-priced rows are corrected by the sync while unpaid; paid historic rows only get the stored delivery charge. @@ -57,8 +58,8 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): **Acceptance Criteria:** - [x] The checkout page fetches the live catalogue (`/api/store/[slug]/products`) on load and whenever the basket changes, and shows each line at the live price; a line whose price differs from the stored basket price shows "Price updated" once and the basket store is updated to the live value. — `lib/checkout/reprice-basket.ts` (pure merge), `app/store/[slug]/checkout/use-checkout-pricing.ts` (fetch keyed on `basketSignature`, so writing the live price back does not refetch), new `replaceItems` on `lib/cart-store.ts`. Place Order is disabled while prices are being checked. - [x] A product no longer listed is removed from the basket with a message, before submit (today it is dropped silently server-side). Also removes a listed product that is no longer orderable (`isAvailable`/`in_stock` false), which the submit route drops too. -- [x] The order summary shows subtotal, Dr Green's delivery charge (from the server cart `localPrices.deliveryCharge` ~~via the existing checkout quote path~~ via a new `GET /api/store/[slug]/checkout/quote` — there was no existing quote path, see §0 correction 2 — or "calculated by Dr Green" when unavailable) and the total; the Place Order button shows the total including delivery (or "+ delivery" when Dr Green has not quoted it). Summary extracted to `checkout-order-summary.tsx` (page was 777 lines). -- [x] Unit test for the re-price merge (`tests/unit/checkout-reprice-basket.test.ts`, `tests/unit/delivery-quote.test.ts`). +- [x] The order summary shows subtotal, Dr Green's delivery charge (from the catalogue's `location.deliveryCharge` when it is in the basket's currency, else from the server cart `localPrices.deliveryCharge` ~~via the existing checkout quote path~~ via a new `GET /api/store/[slug]/checkout/quote` — there was no existing quote path, see §0 correction 2 — or "calculated by Dr Green" when unavailable) and the total; the Place Order button shows the total including delivery (or "+ delivery" when Dr Green has not quoted it). Summary extracted to `checkout-order-summary.tsx` (page was 777 lines). +- [x] Unit test for the re-price merge (`tests/unit/checkout-reprice-basket.test.ts`, `tests/unit/delivery-quote.test.ts`, `tests/unit/checkout-delivery-charge.test.ts`). - [ ] Typecheck/lint passes — *pending: run before the PR (see §0).* - [ ] Verify in browser on a tenant after deploy (no staging) with a test account. @@ -111,4 +112,4 @@ Verified in code 2026-10-02 (`origin/main` 79e0b098): ## 8. Open questions (added while building) -- **Delivery quote source (BS-F01).** Dr Green has no per-market delivery quote a storefront can read before an order exists; the server cart only has one while it holds items. Options: (a) Dr Green adds `deliveryCharge` to the `location` select of `/dapp/strains` (one line, no shape change for other consumers); (b) BudStacks pushes the basket to `POST /dapp/carts` on checkout load (side effects on Dr Green's cart; the submit path already re-pushes). (a) is the clean one. Until then checkout shows "Calculated by Dr Green" and the Place Order button says "+ delivery". +- **Delivery quote source (BS-F01).** Dr Green has no per-market delivery quote a storefront can read before an order exists; the server cart only has one while it holds items. Options: (a) Dr Green adds `deliveryCharge` to the `location` select of `/dapp/strains` (one line, no shape change for other consumers); (b) BudStacks pushes the basket to `POST /dapp/carts` on checkout load (side effects on Dr Green's cart; the submit path already re-pushes). (a) is the clean one. Until then checkout shows "Calculated by Dr Green" and the Place Order button says "+ delivery". **→ (a) built** on the Dr Green hotfix branch and consumed here (§0); open only until that hotfix is on production.