fix(checkout): pay via LNbits payments API directly (drop WalletService dep)

useCheckout was injecting SERVICE_TOKENS.WALLET_SERVICE, but the
restaurant-app bundle only registers base + restaurant modules —
the wallet module isn't bundled because the restaurant surface is
a customer ordering app, not a wallet UI. Result was a hard
'Service not found for token: Symbol(walletService)' on every
checkout setup, which then cascaded into 'Cannot read properties
of undefined (reading 'grandTotal')' downstream.

Two options to fix: (a) bundle the whole wallet module, (b) talk
to LNbits's payments endpoint directly. Going with (b) to match
the market bundle's 'no wallet UI' pattern.

Changes in useCheckout:
  - drop the WalletService import + injectService call
  - new local payBolt11(bolt11, adminkey) helper that POSTs to
    `${apiBaseUrl}/api/v1/payments` with X-Api-Key and
    { out: true, bolt11 } — same shape WalletService.sendPayment
    builds internally
  - balance precheck now reads
    AuthService.user.wallets[0].balance_msat (which LNbits's user
    object carries natively) instead of WalletService.balance.
    Comparison is msat-on-msat — no more sat→msat ×1000 dance
  - explicit 'No wallet available — please log in first.' error
    when the user object lacks wallets[0].adminkey
  - per-bolt11 failure now surfaces the real LNbits error text
    instead of WalletService's boolean swallow

Verified vue-tsc clean. The 'Invalid vnode type' and 'undefined
grandTotal' errors were both downstream of this setup failure;
they go away when setup completes.
This commit is contained in:
Padreug 2026-05-11 18:01:18 +02:00
commit 10abfca555

View file

@ -24,7 +24,7 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import type WalletService from '@/modules/wallet/services/WalletService' import appConfig from '@/app.config'
import type { RestaurantAPI } from '../services/RestaurantAPI' import type { RestaurantAPI } from '../services/RestaurantAPI'
import { useCartStore, type CartLine } from '../stores/cart' import { useCartStore, type CartLine } from '../stores/cart'
import type { import type {
@ -90,10 +90,44 @@ function buildCreateOrder(
export function useCheckout(): UseCheckoutReturn { export function useCheckout(): UseCheckoutReturn {
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API) const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
const wallet = injectService<WalletService>(SERVICE_TOKENS.WALLET_SERVICE)
const cart = useCartStore() const cart = useCartStore()
const { user } = useAuth() const { user } = useAuth()
// 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.
const apiBaseUrl =
(
appConfig.modules.restaurant as
| { config?: { apiBaseUrl?: string } }
| undefined
)?.config?.apiBaseUrl || ''
async function payBolt11(bolt11: string, adminkey: string): Promise<void> {
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<CheckoutState>({ const state = ref<CheckoutState>({
step: 'idle', step: 'idle',
progress: { current: 0, total: 0 }, progress: { current: 0, total: 0 },
@ -141,27 +175,32 @@ export function useCheckout(): UseCheckoutReturn {
quotes.push({ ...b, msat: quote.required_msat }) quotes.push({ ...b, msat: quote.required_msat })
} }
// 2. Pre-flight balance check. The webapp's WalletService // 2. Pre-flight balance check using AuthService's cached wallet
// exposes balance via PaymentService; we ask the service for // balance (LNbits's user object carries balance_msat per wallet).
// the current cached value via its public computed.
// NOTE: balance values in this codebase are sats, not msat —
// convert before comparing.
const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0) const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0)
const balanceSat = ( const wallet0 = user.value?.wallets?.[0]
wallet as unknown as { balance?: { value?: number } } if (wallet0 && typeof wallet0.balance_msat === 'number') {
).balance?.value if (wallet0.balance_msat < totalMsatRequired) {
if (typeof balanceSat === 'number') { const needSat = Math.ceil(totalMsatRequired / 1000)
const balanceMsat = balanceSat * 1000 const haveSat = Math.floor(wallet0.balance_msat / 1000)
if (balanceMsat < totalMsatRequired) {
state.value = { state.value = {
step: 'error', step: 'error',
progress: { current: 0, total: buckets.length }, progress: { current: 0, total: buckets.length },
currentRestaurantSlug: null, currentRestaurantSlug: null,
error: `Insufficient balance. Need ${Math.ceil(totalMsatRequired / 1000)} sat, have ${balanceSat} sat.`, error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`,
} }
throw new Error(state.value.error!) throw new Error(state.value.error!)
} }
} }
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. // 3. Place orders.
state.value.step = 'placing' state.value.step = 'placing'
@ -196,13 +235,20 @@ export function useCheckout(): UseCheckoutReturn {
state.value.progress = { current: i, total: placed.length } state.value.progress = { current: i, total: placed.length }
if (!p.invoice) continue // cash orders skip payment if (!p.invoice) continue // cash orders skip payment
const success = await wallet.sendPayment({ try {
amount: Math.ceil(p.invoice.amount_msat / 1000), await payBolt11(p.invoice.bolt11, wallet0.adminkey)
destination: p.invoice.bolt11, if (p.invoice.payment_hash) {
comment: `Order at ${p.restaurantSlug}`, paidHashes.push(p.invoice.payment_hash)
}) }
if (success && p.invoice.payment_hash) { } catch (err) {
paidHashes.push(p.invoice.payment_hash) 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
} }
} }