Compare commits

...

3 commits

Author SHA1 Message Date
8203c54feb Merge pull request 'fix(core): read the routing fee from fee, not the absent fee_msat' (#171) from fix/fee-field-name into dev
Reviewed-on: #171
2026-09-26 12:37:30 +00:00
21d4e0e4df fix(market): declare feeMsat on Order so the persisted fee is typed
`useLightningPayment` writes feeMsat onto the paid order, but `Order`
never declared it. `updateOrder` takes `Partial<Order>`, and the write
goes through an inferred variable, so the excess-property check never
fired — the value landed in the store invisible to the type system and
unreadable by any consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 14:26:29 +02:00
7d4cb3d49d fix(core): read the routing fee from fee, not the absent fee_msat
Closes #170.

LNbits payment payloads carry `fee`, signed millisats like `amount`.
There is no `fee_msat` field, so three modules read `undefined`:

  PaymentService.PaymentResult declared `fee_msat: number` — a field the
  API never sends — so the compiler vouched for a number that was always
  undefined, and the lie propagated to every consumer.

  market/useLightningPayment persisted `feeMsat: undefined` onto paid
  orders (on a property the Order type does not even declare).

  events/LnbitsPaymentProvider had `data.fee_msat ?? 0`, whose fallback
  masked the miss so every payment recorded a zero fee.

`payInvoice` now normalizes at the boundary instead of returning the raw
payload: it maps `fee` to a positive `feeMsat`. Doing it once there means
no call site can pick the wrong field name or forget that outgoing
amounts are signed. `PaymentResult.feeMsat` replaces the phantom field,
so the type finally matches what callers receive.

Verified against a live pay-invoice response, plus a synthetic payment
carrying a real routing fee (internal LNbits transfers are fee-free, so
the live case cannot exercise a non-zero fee):

  live response       fee present: True   fee_msat present: False
  synthetic -1234 msat
    old (events)  -> 0      fee silently lost
    old (market)  -> undefined
    new (both)    -> 1234   positive magnitude

Nothing read these values downstream, so no totals were miscomputed; the
fee was simply recorded as missing or zero.
2026-09-23 09:54:09 +02:00
4 changed files with 26 additions and 6 deletions

View file

@ -5,7 +5,15 @@ import { toast } from 'vue-sonner'
export interface PaymentResult {
payment_hash: string
fee_msat: number
/**
* Routing fee in millisats, always positive.
*
* LNbits returns this as `fee`, not `fee_msat`, and signs it negative for
* outgoing payments (like `amount`). This interface previously declared
* `fee_msat: number`, a field the API never sends, so every consumer read
* `undefined` while the compiler vouched for a number.
*/
feeMsat: number
preimage: string
}
@ -230,7 +238,16 @@ export class PaymentService extends BaseService {
throw new Error(errorMessage)
}
return await response.json()
// Normalize at the boundary rather than handing the raw LNbits payload
// to callers: `fee` is signed millisats, and consumers all want a
// positive magnitude. Doing it here means no call site can pick the
// wrong field name or forget the sign.
const raw = await response.json()
return {
payment_hash: raw.payment_hash,
feeMsat: Math.abs(Number(raw.fee ?? 0)),
preimage: raw.preimage ?? ''
}
}
/**
@ -266,7 +283,7 @@ export class PaymentService extends BaseService {
this.debug('Payment successful', {
paymentHash: paymentResult.payment_hash,
feeMsat: paymentResult.fee_msat
feeMsat: paymentResult.feeMsat
})
// Show success notification if enabled

View file

@ -84,7 +84,9 @@ export class LnbitsPaymentProvider implements PaymentProvider {
const data = await response.json()
return {
paymentHash: data.payment_hash,
feeMsat: data.fee_msat ?? 0,
// LNbits sends `fee` (signed millisats), never `fee_msat`. Reading the
// latter meant every payment recorded a zero fee.
feeMsat: Math.abs(Number(data.fee ?? 0)),
preimage: data.preimage ?? '',
}
}

View file

@ -42,7 +42,7 @@ export function useLightningPayment() {
status: 'paid' as const,
paidAt: Math.floor(Date.now() / 1000),
paymentHash: paymentResult.payment_hash,
feeMsat: paymentResult.fee_msat,
feeMsat: paymentResult.feeMsat,
items: [...order.items], // Convert readonly to mutable
shippingZone: order.shippingZone ? {
...order.shippingZone,
@ -88,7 +88,7 @@ export function useLightningPayment() {
status: 'paid' as const,
paidAt: Math.floor(Date.now() / 1000),
paymentHash: result.payment_hash,
feeMsat: result.fee_msat,
feeMsat: result.feeMsat,
items: [...order.items], // Convert readonly to mutable
shippingZone: order.shippingZone ? {
...order.shippingZone,

View file

@ -129,6 +129,7 @@ export interface Order {
originalOrderId?: string
lightningInvoice?: any
paymentHash?: string
feeMsat?: number // Routing fee paid on the Lightning payment, in millisats
paidAt?: number
paymentStatus?: 'pending' | 'paid' | 'expired'
paid?: boolean // Direct boolean field matching nostrmarket reference