Files

224 lines
13 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:b635cddd04e7b73f066847eaf0cbf8dfca213ceaa0e4c52ee3c3820f5df5cd3a
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { createEvent, deleteEvent } from './events.js';
import { linkProposalTxSoft } from './tx-links.js';
import { memoNote } from '../../shared/zap.js';
const MEMO_OF = `COALESCE(w.memo, (SELECT twin.memo FROM WalletTransaction twin
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND twin.accountId = w.accountId
AND twin.txid = w.txid AND twin.id <> w.id AND twin.memo IS NOT NULL
ORDER BY twin.id LIMIT 1))`;
const NODE_ACCOUNT_OF = `(SELECT node.accountId FROM WalletSource onchain
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
WHERE onchain.accountId = w.accountId AND onchain.kind IN ('xpub','address')
AND node.accountId <> w.accountId
ORDER BY node.id LIMIT 1)`;
function bookingNote(p, prefix = 'reconcile tx', zonderHash = 'reconcile') {
const memo = p.walletTransactionId
? db.one(`SELECT ${MEMO_OF} AS memo FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId])?.memo : null;
const basis = p.transactionHash ? `${prefix} ${p.transactionHash}` : zonderHash;
const context = memoNote(memo, p.direction);
return context ? `${context} · ${basis}` : basis;
}
export function listPendingProposals() {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
w.blockTime AS tx_block_time, w.layer AS tx_layer, w.kind AS tx_kind, w.feeSats AS tx_fee_sats,
${MEMO_OF} AS tx_memo,
CASE WHEN w.kind = 'channel' THEN (SELECT name FROM Account WHERE id = ${NODE_ACCOUNT_OF}) END AS tx_node_account,
-- The NWC wallet the payment went through (user, 19-10-2026): its row with the same hash.
(SELECT COALESCE(s.name, '') FROM WalletTransaction twin
JOIN WalletSource s ON s.id = twin.sourceId
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND s.kind = 'nwc'
AND twin.accountId = w.accountId AND twin.txid = w.txid AND twin.id <> w.id
ORDER BY twin.id LIMIT 1) AS via_wallet
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status = 'pending'
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = p.walletTransactionId)
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC`);
}
export async function syncTxProposals(accountId, assetId) {
db.run(`DELETE FROM ReconcileProposal WHERE status='pending' AND accountId=?1 AND assetId=?2`, [accountId, assetId]);
db.run(`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
SELECT w.accountId, ?2, w.id, w.txid, w.valueSats, w.direction,
CASE WHEN ac.isOwnCustody THEN 'transfer' ELSE 'trade' END, 'pending', NULL, ?3
FROM WalletTransaction w
JOIN Account ac ON ac.id = w.accountId
WHERE w.accountId = ?1
-- LIGHTNING ONLY FROM THE NODE: an NWC wallet is a grouping
-- on top of LND, so its payment is booked once, from LND, and the NWC row takes the event
-- over by payment hash. On an account WITHOUT a LND source the NWC wallet counts on its own,
-- as in its total on Tracking. The same rule stands in hygiene.js.
AND (w.layer = 'onchain'
OR EXISTS (SELECT 1 FROM WalletSource s
WHERE s.id = w.sourceId
AND (s.kind = 'lnd'
OR (s.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = w.accountId AND node.kind = 'lnd')))))
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = w.id)
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.status = 'ignored'
AND ( p.walletTransactionId = w.id
-- The amount match only for an ignored proposal WITHOUT a movement
-- (a gap proposal). One that hangs on a movement means "this one",
-- and matching it on amount suppressed every other movement of the
-- same size: one ignored 21-sat zap hid every 21-sat zap.
OR (p.walletTransactionId IS NULL
AND p.accountId = w.accountId AND p.assetId = ?2
AND p.direction = w.direction AND p.amount = w.valueSats) ))`,
[accountId, assetId, nowIso()]);
const n = db.one(`SELECT changes() AS n`).n;
await db.persist();
return n;
}
function getProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status !== 'pending') {
fail(`home.recHandled_${p.status}`, { id }, `Proposal ${id} has already been handled (${p.status}).`);
}
return p;
}
export async function resolveProposalAsTrade(id, { fiatPrice, fiatFee, occurredAt, priceOrigin }) {
const p = getProposal(id);
if (fiatPrice === undefined || fiatPrice === null) throw new Error('fiatPrice is required for a trade.');
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const type = p.direction === 'in' ? 'buy' : 'sell';
const txFee = type === 'sell' && wtx && wtx.feeSats ? wtx.feeSats : null;
const eventId = await createEvent({
assetId: p.assetId, type, occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice, fiatFee: fiatFee ?? null, unitFee: txFee,
priceOrigin: priceOrigin ?? null,
fromAccountId: type === 'sell' ? p.accountId : null,
toAccountId: type === 'buy' ? p.accountId : null,
source: 'reconcile', note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_trade', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsTransfer(id, { other_account_id, unitFee, occurredAt }) {
const p = getProposal(id);
if (!other_account_id) {
fail('home.recNoCounter', null, 'A counter-account is required for a transfer.');
}
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const txFee = p.direction === 'out' && wtx && wtx.feeSats != null ? wtx.feeSats : null;
const fromAccountId = p.direction === 'out' ? p.accountId : other_account_id;
const toAccountId = p.direction === 'out' ? other_account_id : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: unitFee ?? txFee,
fromAccountId, toAccountId, source: 'reconcile',
note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInternal(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT w.blockTime, w.feeSats, w.kind, ${NODE_ACCOUNT_OF} AS nodeAccountId
FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.kind == null) {
fail('home.recNotInternal', { id }, `Proposal ${id} is not a movement inside the account.`);
}
const ander = wtx.kind === 'channel' && wtx.nodeAccountId ? wtx.nodeAccountId : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null,
unitFee: p.direction === 'out' && wtx.feeSats ? wtx.feeSats : null,
fromAccountId: p.direction === 'out' ? p.accountId : ander,
toAccountId: p.direction === 'out' ? ander : p.accountId, source: 'reconcile',
note: bookingNote(p, `reconcile ${wtx.kind}`, `reconcile ${wtx.kind}`),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInterest(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT blockTime, layer, txid FROM WalletTransaction WHERE id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.layer !== 'lightning' || !String(wtx.txid).startsWith('fwd:') || p.direction !== 'in') {
fail('home.recNotForward', { id }, `Proposal ${id} is not a lightning forward.`);
}
const eventId = await createEvent({
assetId: p.assetId, type: 'interest', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: p.accountId, source: 'reconcile', note: bookingNote(p, 'reconcile forward', 'reconcile forward'),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_interest', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function ignoreProposal(id) {
getProposal(id);
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE id=?`, [id]);
await db.persist();
}
export async function deleteProposal(id) {
db.run(`DELETE FROM ReconcileProposal WHERE id=?`, [id]);
await db.persist();
}
export async function ignoreAllPendingProposals() {
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE status='pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function deleteAllPendingProposals() {
db.run(`DELETE FROM ReconcileProposal WHERE status = 'pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function revertProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status === 'accepted_trade' || p.status === 'accepted_transfer' || p.status === 'accepted_interest') {
if (p.resolvedEventId) await deleteEvent(p.resolvedEventId);
db.run(`UPDATE ReconcileProposal SET status = 'pending', resolvedEventId = NULL WHERE id = ?`, [id]);
} else if (p.status === 'ignored') {
if (p.walletTransactionId) db.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
else db.run(`UPDATE ReconcileProposal SET status = 'pending' WHERE id = ?`, [id]);
} else if (p.status === 'auto_linked') {
fail('home.recAutoLinked', { id }, `Proposal ${id} is auto-linked; unlink it through the match popover.`);
} else {
fail('home.recStillOpen', { id }, `Proposal ${id} (${p.status}) cannot be reverted.`);
}
await db.persist();
}
export function listHandledProposals(limit = 50) {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
e.type AS event_type, w.blockTime AS tx_block_time
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN Event e ON e.id = p.resolvedEventId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status != 'pending'
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC
LIMIT ?`, [limit]);
}