diff --git a/src/modules/restaurant/components/CartLineItem.vue b/src/modules/restaurant/components/CartLineItem.vue index 0149867..cd27b60 100644 --- a/src/modules/restaurant/components/CartLineItem.vue +++ b/src/modules/restaurant/components/CartLineItem.vue @@ -10,7 +10,6 @@ import type { CartLine } from '../stores/cart' const props = defineProps<{ line: CartLine - currencyHint?: string }>() const emit = defineEmits<{ @@ -24,14 +23,12 @@ const modifierSummary = computed(() => ) const lineTotal = computed(() => { - // unit_msat is base + modifier delta snapshot; the cart store - // currently stores it as a sat-major number (price * 1000-less - // because the extension's `price` is already in the declared - // currency, not msat — see useCheckout's buildCreateOrder for the - // canonical conversion at order-place time). + // Displayed in the menu item's currency (e.g. GTQ). Authoritative + // sat conversion happens server-side at /orders/quote — the + // checkout page surfaces that as a "≈ X sat" badge. const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }) - const total = props.line.unit_msat * props.line.quantity - return `${fmt.format(total)} ${props.currencyHint || 'sat'}` + const total = props.line.unit_price * props.line.quantity + return `${fmt.format(total)} ${props.line.currency}` }) diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts index d922c29..f42e6c8 100644 --- a/src/modules/restaurant/composables/useCheckout.ts +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -1,25 +1,31 @@ /** - * useCheckout — orchestrates the place-order + pay-bolt11 sequence - * for every restaurant currently in the cart. + * useCheckout — drives the customer's place-and-pay flow against + * the restaurant extension. * * v1 ships REST-only: - * for each restaurant in the cart: - * 1. quote (msat required) - * 2. balance pre-check (sum across all restaurants) - * 3. placeOrder → { order, invoice } - * 4. WalletService.sendPayment(bolt11) + * 1. quote (msat required) + * 2. balance pre-check (sum across all restaurants in the cart) + * 3. POST /orders → { order, invoice } + * 4. (optional, customer choice) POST /api/v1/payments to settle + * the bolt11 from the customer's LNbits wallet. They can also + * skip this step and scan the QR with any other wallet — the + * extension's invoice listener marks the order paid either way. * - * The festival aggregator (aiolabs/restaurant#8) exercises this - * same path with N > 1 restaurants in the cart. v1 happens to ship - * a UI where N == 1, but the orchestration is multi-restaurant - * already. + * Split into two distinct actions so the UI can render the QR codes + * between place and pay, giving the customer the option to scan + * with an external wallet rather than auto-paying from LNbits: * - * NIP-17 transport (aiolabs/restaurant#9) plugs in here later as a - * `transport: 'rest' | 'nostr'` option that gift-wraps the - * CreateOrder instead of POSTing it. The `buildCreateOrder` - * helper is the single point both transports build through, so - * adding loyalty (aiolabs/restaurant#5) is also a one-function - * change rather than touching every call site. + * placeOrders() — runs steps 1-3, populates `state.value.placedOrders` + * payOrder(idx) — runs step 4 for one placed-order index + * + * The festival aggregator (aiolabs/restaurant#8) exercises this same + * path with N > 1 restaurants in the cart. v1 happens to ship a UI + * where N == 1, but the orchestration is multi-restaurant ready. + * + * NIP-17 transport (aiolabs/restaurant#9) plugs in via the + * `buildCreateOrder` helper — single point both REST and Nostr + * transports construct CreateOrder. Loyalty (#5) injects its + * pass-through fields the same way. */ import { ref, type Ref } from 'vue' import { injectService, SERVICE_TOKENS } from '@/core/di-container' @@ -42,28 +48,46 @@ export interface PlacedOrder { invoice: OrderInvoice | null } -export interface CheckoutResult { - placedOrders: PlacedOrder[] - paidPaymentHashes: string[] -} - export interface CheckoutState { - step: 'idle' | 'quoting' | 'placing' | 'paying' | 'done' | 'error' + step: + | 'idle' + | 'quoting' + | 'placing' + | 'placed' + | 'paying' + | 'paid' + | 'error' progress: { current: number; total: number } currentRestaurantSlug: string | null + placedOrders: PlacedOrder[] + /** Set of `placedOrders[i].order.id` that have been auto-paid + * via LNbits in this session. External-wallet payments don't + * populate this — they're detected via the per-order poller in + * CheckoutPage. */ + paidOrderIds: Set error: string | null } export interface UseCheckoutReturn { state: Ref - checkout: () => Promise + /** Run quote → balance precheck → POST /orders for every cart + * bucket. Returns the placed orders (also persisted in state). */ + placeOrders: () => Promise + /** Pay one already-placed order's bolt11 from the customer's + * LNbits wallet. Idempotent — calling twice on the same order + * is a no-op after the first success. */ + payOrder: (placedIndex: number) => Promise + /** Pay every unpaid placed-order in sequence. Best-effort: if + * one fails, earlier successes stay paid. */ + payAll: () => Promise + /** Reset to idle and drop placed/paid state. */ + reset: () => void } /** * The single point CreateOrder is built — keeps loyalty (#5), NIP-17 * transport (#9), and tip overrides one-place changes rather than - * touching the whole flow. Today loyalty is unconfigured so the - * extra block stays at its defaults. + * touching the whole flow. */ function buildCreateOrder( restaurantId: string, @@ -76,7 +100,6 @@ function buildCreateOrder( selected_modifiers: l.selected_modifiers, note: l.note ?? undefined, })) - // Loyalty (#5) future-extension point: when implemented, inject // { loyalty_credits_msat, loyalty_pubkey } into extra.fields here. return { @@ -88,6 +111,17 @@ function buildCreateOrder( } } +function blankState(): CheckoutState { + return { + step: 'idle', + progress: { current: 0, total: 0 }, + currentRestaurantSlug: null, + placedOrders: [], + paidOrderIds: new Set(), + error: null, + } +} + export function useCheckout(): UseCheckoutReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) const cart = useCartStore() @@ -95,9 +129,7 @@ export function useCheckout(): UseCheckoutReturn { // We talk to LNbits's payments endpoint directly rather than // pulling in the whole `wallet` module — the restaurant-app - // bundle is a customer surface, not a wallet UI, and `LnbitsAPI` - // is already registered by base. The customer's adminkey lives - // on AuthService.user.wallets[0].adminkey. + // bundle is a customer surface, not a wallet UI. const apiBaseUrl = ( appConfig.modules.restaurant as @@ -105,52 +137,30 @@ export function useCheckout(): UseCheckoutReturn { | undefined )?.config?.apiBaseUrl || '' - async function payBolt11(bolt11: string, adminkey: string): Promise { - const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Api-Key': adminkey, - }, - body: JSON.stringify({ out: true, bolt11 }), - }) - if (!response.ok) { - let detail = response.statusText - try { - const body = await response.json() - if (body?.detail) detail = body.detail - } catch { - /* body wasn't JSON */ - } - throw new Error( - `Payment failed: ${response.status} ${detail}` - ) - } + const state = ref(blankState()) + + function reset(): void { + state.value = blankState() } - const state = ref({ - step: 'idle', - progress: { current: 0, total: 0 }, - currentRestaurantSlug: null, - error: null, - }) + // ----------------------------------------------------------------- // + // place // + // ----------------------------------------------------------------- // - async function checkout(): Promise { + async function placeOrders(): Promise { const buckets = cart.restaurantsInCart.map((rid) => ({ restaurantId: rid, restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', lines: cart.linesFor(rid), })) - if (!buckets.length) { throw new Error('Cart is empty') } state.value = { + ...blankState(), step: 'quoting', progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: null, } // 1. Quote per restaurant. @@ -175,32 +185,23 @@ export function useCheckout(): UseCheckoutReturn { quotes.push({ ...b, msat: quote.required_msat }) } - // 2. Pre-flight balance check using AuthService's cached wallet - // balance (LNbits's user object carries balance_msat per wallet). + // 2. Balance pre-check. const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0) const wallet0 = user.value?.wallets?.[0] if (wallet0 && typeof wallet0.balance_msat === 'number') { if (wallet0.balance_msat < totalMsatRequired) { const needSat = Math.ceil(totalMsatRequired / 1000) const haveSat = Math.floor(wallet0.balance_msat / 1000) - state.value = { - step: 'error', - progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`, - } - throw new Error(state.value.error!) + // Not fatal — the customer may still want to scan the QR + // and pay from an external wallet. Surface a warning but + // continue. + console.warn( + `[restaurant] LNbits wallet balance is below the cart total ` + + `(have ${haveSat} sat, need ${needSat} sat). Auto-pay will ` + + `fail; scan the QR with an external wallet to settle.` + ) } } - if (!wallet0?.adminkey) { - state.value = { - step: 'error', - progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: 'No wallet available — please log in first.', - } - throw new Error(state.value.error!) - } // 3. Place orders. state.value.step = 'placing' @@ -223,49 +224,86 @@ export function useCheckout(): UseCheckoutReturn { }) } - // 4. Pay each bolt11 sequentially. If a payment fails, the - // earlier successes are still placed-and-paid — best-effort - // is the v1 model (see plan; HODL atomicity is a future - // issue). - state.value.step = 'paying' - const paidHashes: string[] = [] - for (let i = 0; i < placed.length; i++) { - const p = placed[i] - state.value.currentRestaurantSlug = p.restaurantSlug - state.value.progress = { current: i, total: placed.length } - if (!p.invoice) continue // cash orders skip payment - - try { - await payBolt11(p.invoice.bolt11, wallet0.adminkey) - if (p.invoice.payment_hash) { - paidHashes.push(p.invoice.payment_hash) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - state.value = { - step: 'error', - progress: { current: i, total: placed.length }, - currentRestaurantSlug: p.restaurantSlug, - error: msg, - } - throw err - } - } - - // 5. Clear the paid lines from the cart. - for (const p of placed) { - cart.clearRestaurant(p.restaurantId) - } - - state.value = { - step: 'done', - progress: { current: placed.length, total: placed.length }, - currentRestaurantSlug: null, - error: null, - } - - return { placedOrders: placed, paidPaymentHashes: paidHashes } + state.value.step = 'placed' + state.value.placedOrders = placed + state.value.currentRestaurantSlug = null + return placed } - return { state, checkout } + // ----------------------------------------------------------------- // + // pay // + // ----------------------------------------------------------------- // + + async function payBolt11Raw( + bolt11: string, + adminkey: string + ): Promise { + const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': adminkey, + }, + body: JSON.stringify({ out: true, bolt11 }), + }) + if (!response.ok) { + let detail = response.statusText + try { + const body = await response.json() + if (body?.detail) detail = body.detail + } catch { + /* body wasn't JSON */ + } + throw new Error(`Payment failed: ${response.status} ${detail}`) + } + } + + async function payOrder(placedIndex: number): Promise { + const placed = state.value.placedOrders[placedIndex] + if (!placed) throw new Error(`No placed order at index ${placedIndex}`) + if (!placed.invoice) return // cash orders skip payment + if (state.value.paidOrderIds.has(placed.order.id)) return // already paid + + const adminkey = user.value?.wallets?.[0]?.adminkey + if (!adminkey) { + throw new Error('No wallet available — please log in first.') + } + + state.value.step = 'paying' + state.value.currentRestaurantSlug = placed.restaurantSlug + try { + await payBolt11Raw(placed.invoice.bolt11, adminkey) + // Set semantics keeps `paidOrderIds` from re-renders; rebuild + // it on update so Vue picks up the change. + state.value.paidOrderIds = new Set([ + ...state.value.paidOrderIds, + placed.order.id, + ]) + // Bump to 'paid' only when every placed order is paid. + if ( + state.value.placedOrders.every((p) => + state.value.paidOrderIds.has(p.order.id) + ) + ) { + state.value.step = 'paid' + } else { + state.value.step = 'placed' + } + } catch (err) { + state.value.step = 'error' + state.value.error = err instanceof Error ? err.message : String(err) + throw err + } + } + + async function payAll(): Promise { + for (let i = 0; i < state.value.placedOrders.length; i++) { + const p = state.value.placedOrders[i] + if (!p.invoice) continue + if (state.value.paidOrderIds.has(p.order.id)) continue + await payOrder(i) + } + } + + return { state, placeOrders, payOrder, payAll, reset } } diff --git a/src/modules/restaurant/stores/cart.ts b/src/modules/restaurant/stores/cart.ts index 7beb1e4..dfc6839 100644 --- a/src/modules/restaurant/stores/cart.ts +++ b/src/modules/restaurant/stores/cart.ts @@ -30,8 +30,16 @@ export interface CartLine { /** Snapshot at add-time so the cart still renders if the menu * item is later edited / deleted. */ name: string - /** Base price + selected modifier deltas, in msat. */ - unit_msat: number + /** + * Base price + selected modifier deltas, **in the menu item's + * declared currency** (e.g. 25 for "25 GTQ" or 100 for "100 sat"). + * Authoritative sat conversion happens server-side via + * `POST /orders/quote`; the cart's value is for display only. + */ + unit_price: number + /** ISO-ish currency code from the menu item, e.g. "GTQ", "USD", + * "sat". Used for cart-side display labels. */ + currency: string quantity: number selected_modifiers: SelectedModifier[] note?: string | null @@ -91,19 +99,42 @@ export const useCartStore = defineStore('restaurant-cart', () => { return n }) - const restaurantTotalsMsat = computed>(() => { - const out: Record = {} + /** Per-restaurant subtotal in that restaurant's declared currency. */ + const restaurantTotals = computed< + Record + >(() => { + const out: Record = {} for (const rid of Object.keys(lines.value)) { - out[rid] = lines.value[rid].reduce( - (s, l) => s + l.unit_msat * l.quantity, - 0 - ) + const bucket = lines.value[rid] + if (!bucket.length) continue + out[rid] = { + amount: bucket.reduce( + (s, l) => s + l.unit_price * l.quantity, + 0 + ), + currency: bucket[0].currency, + } } return out }) - const grandTotalMsat = computed(() => - Object.values(restaurantTotalsMsat.value).reduce((s, v) => s + v, 0) + /** + * Single-currency cart subtotal. Returns null if the cart spans + * multiple currencies (e.g. one restaurant priced in GTQ + another + * in USD via the future festival aggregator) — the UI then falls + * back to per-restaurant subtotals only. + */ + const grandTotal = computed<{ amount: number; currency: string } | null>( + () => { + const totals = Object.values(restaurantTotals.value) + if (!totals.length) return null + const currencies = new Set(totals.map((t) => t.currency)) + if (currencies.size !== 1) return null + return { + amount: totals.reduce((s, t) => s + t.amount, 0), + currency: totals[0].currency, + } + } ) function linesFor(restaurantId: string): CartLine[] { @@ -214,8 +245,8 @@ export const useCartStore = defineStore('restaurant-cart', () => { // getters restaurantsInCart, itemCount, - restaurantTotalsMsat, - grandTotalMsat, + restaurantTotals, + grandTotal, linesFor, // actions setActiveRestaurant, diff --git a/src/modules/restaurant/types/restaurant.ts b/src/modules/restaurant/types/restaurant.ts index 87a61cf..d7044b8 100644 --- a/src/modules/restaurant/types/restaurant.ts +++ b/src/modules/restaurant/types/restaurant.ts @@ -233,6 +233,34 @@ export const KNOWN_ORDER_STATUSES = [ export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number] export type OrderStatus = string +/** + * Customer-facing labels for order statuses. The extension's raw + * status names are operational ('paid' / 'accepted' / 'ready') but + * customers prefer human-friendly framing ('Order received' / + * 'Cooking' / 'Ready for pickup'). + * + * Future statuses from aiolabs/restaurant#4 (kitchen workflow) — + * 'preparing', 'plating', 'at_pass', 'in_service', etc — can land + * here as they arrive. Unknown values fall through to the raw + * status string titlecased. + */ +export const FRIENDLY_ORDER_STATUS: Record = { + pending: 'Awaiting payment', + paid: 'Order received', + accepted: 'Cooking', + ready: 'Ready for pickup', + completed: 'Served', + canceled: 'Canceled', + refunded: 'Refunded', +} + +export function friendlyOrderStatus(status: OrderStatus): string { + if (status in FRIENDLY_ORDER_STATUS) return FRIENDLY_ORDER_STATUS[status] + // Unknown status — titlecase the raw key as a graceful fallback. + if (!status) return '' + return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ') +} + export interface Order { id: string restaurant_id: string diff --git a/src/modules/restaurant/views/CartPage.vue b/src/modules/restaurant/views/CartPage.vue index 7771c85..a485420 100644 --- a/src/modules/restaurant/views/CartPage.vue +++ b/src/modules/restaurant/views/CartPage.vue @@ -25,12 +25,12 @@ const buckets = computed(() => // a separate fetch for the cart page header. restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', lines: cart.linesFor(rid), - totalMsat: cart.restaurantTotalsMsat[rid] ?? 0, + total: cart.restaurantTotals[rid] ?? null, })) ) -function fmtSat(value: number): string { - return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat` +function fmt(value: number, currency: string): string { + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}` } @@ -70,7 +70,7 @@ function fmtSat(value: number): string { - {{ fmtSat(b.totalMsat) }} + {{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }} @@ -91,12 +91,16 @@ function fmtSat(value: number): string {
-
+
Subtotal - {{ fmtSat(cart.grandTotalMsat) }} + {{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
+

+ Cart spans multiple currencies — per-restaurant subtotals above. + Lightning sat amount shown at checkout. +

- {{ order.status }} + {{ friendlyOrderStatus(order.status) }} @@ -161,13 +162,25 @@ const timeline = computed(() => { /> - Payment received + Order received - The kitchen is on it. + Payment confirmed — the kitchen will start preparing it + shortly. + + + + + + Cooking + + Your food is being made. @@ -176,12 +189,21 @@ const timeline = computed(() => { class="mb-4 border-amber-500/40" > - Ready + Ready for pickup Pick up at the counter. + + + Served + Enjoy! Thanks for ordering. + + Items diff --git a/src/modules/restaurant/views/RestaurantPage.vue b/src/modules/restaurant/views/RestaurantPage.vue index 3d97877..d9f06f0 100644 --- a/src/modules/restaurant/views/RestaurantPage.vue +++ b/src/modules/restaurant/views/RestaurantPage.vue @@ -84,7 +84,8 @@ function quickAdd(itemId: string) { restaurant_slug: restaurant.value.slug, menu_item_id: it.id, name: it.name, - unit_msat: it.price, + unit_price: it.price, + currency: it.currency || restaurant.value.currency, quantity: 1, selected_modifiers: [], note: null,