Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,224 @@
|
||||
// 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]);
|
||||
}
|
||||
Reference in new issue
Block a user