Files
AtreidesAssetsFree/server/data/events.js
T
2026-10-29 11:57:29 +01:00

262 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generated, do not edit. sha256:68008a6906cce53854e3533a4a9207b146e9943ef96ac00c6c0dd8b1e47f76f3
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, assertTimestamp, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listEvents() {
return db.all(`SELECT * FROM Event WHERE type <> 'reservation' ORDER BY occurredAt DESC, id DESC`);
}
export const EVENT_JOIN = `
SELECT e.*, 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, a.displayUnit AS asset_display_unit,
a.kind AS asset_kind,
lf.name AS from_name, lt.name AS to_name,
mp.name AS product_name, mp.form AS product_form
FROM Event e
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
LEFT JOIN MetalProduct mp ON mp.id = e.productId`;
export function listTrades() {
return db.all(`${EVENT_JOIN} WHERE e.type IN ('buy','sell') ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listTransfers() {
return db.all(`${EVENT_JOIN} WHERE e.type = 'transfer' ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listAssetTrades(assetId) {
return db.all(`SELECT e.id, e.type, e.amount, e.fiatPrice, e.fiatFee, e.occurredAt,
e.fromAccountId, e.toAccountId,
lf.name AS from_name, lt.name AS to_name,
(SELECT GROUP_CONCAT(DISTINCT l.name)
FROM WalletTransactionEventLink wl
JOIN WalletTransaction wt ON wt.id = wl.walletTransactionId
JOIN Label l ON l.id = wt.labelId
WHERE wl.eventId = e.id) AS label_names
FROM Event e
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE e.assetId = ? AND e.type IN ('buy','sell')
ORDER BY e.occurredAt ASC, e.id ASC`, [assetId]);
}
function assertEventPayload(e) {
assertEnum('type', ENUMS.EVENT_TYPE, e.type);
assertEnum('source', ENUMS.EVENT_SOURCE, e.source);
if ((e.type === 'buy' || e.type === 'sell') && (e.fiatPrice === undefined || e.fiatPrice === null)) {
throw new Error(`fiatPrice is required for type '${e.type}'.`);
}
if (e.type === 'interest' && (Number(e.fiatFee) > 0 || Number(e.unitFee) > 0)) {
fail('trades.feeOnInterest', null, 'A fee on interest counts nowhere; it is refused.');
}
if (e.type === 'buy' && Number(e.unitFee) > 0) {
fail('trades.unitFeeOnBuy', null, 'A fee in units on a purchase counts nowhere; lower the amount instead.');
}
if (e.type === 'reservation') {
if ((e.fromAccountId == null) === (e.toAccountId == null)) throw new Error('A reservation sits on exactly one account.');
if (e.fiatPrice != null || e.fiatFee != null || e.unitFee != null || e.productId != null
|| e.priceOrigin != null || e.importReference != null || e.externalAddress != null) {
throw new Error('A reservation carries no price, fee, product, origin, import reference or address.');
}
}
assertTimestamp('occurredAt', e.occurredAt);
assertProductAmount(e);
}
function priceOriginOf(value) {
if (value == null || value === '') return null;
assertEnum('priceOrigin', ENUMS.EVENT_PRICE_ORIGIN, value);
return value;
}
function assertProductAmount(e) {
if (e.productId == null) return;
const p = db.one(`SELECT fineGrams FROM MetalProduct WHERE id = ?`, [e.productId]);
if (!p) throw new Error(`Metal product ${e.productId} does not exist.`);
const verwacht = e.productCount * p.fineGrams;
if (e.amount !== verwacht) {
throw new Error(`amount (${e.amount}) should be ${e.productCount} × ${p.fineGrams} = ${verwacht} ` +
`for this product; adjust the count or the amount.`);
}
}
export async function createEvent(e) {
assertEventPayload(e);
db.run(`INSERT INTO Event
(assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount, fromAccountId, toAccountId, source, note, importReference, externalAddress, priceOrigin, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.importReference ?? null, e.externalAddress ?? null,
priceOriginOf(e.priceOrigin), nowIso()]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
export function findEventByRef(ref) {
if (!ref) return null;
return db.one(`SELECT * FROM Event WHERE importReference = ?`, [ref]) || null;
}
export function eventsWithoutImportRef() {
return db.all(`SELECT * FROM Event WHERE importReference IS NULL AND type <> 'reservation'`);
}
export async function updateEvent(id, e) {
assertEventPayload(e);
const origin = Object.hasOwn(e, 'priceOrigin')
? priceOriginOf(e.priceOrigin)
: (db.one(`SELECT priceOrigin AS o FROM Event WHERE id = ?`, [id]) || {}).o ?? null;
trackUpdate('Event', id, () =>
db.run(`UPDATE Event SET
assetId = ?, type = ?, occurredAt = ?, amount = ?, fiatPrice = ?, fiatFee = ?,
unitFee = ?, productId = ?, productCount = ?, fromAccountId = ?, toAccountId = ?, source = ?, note = ?, externalAddress = ?,
priceOrigin = ?
WHERE id = ?`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.externalAddress ?? null,
origin, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteEvent(id) {
trackDelete('Event', id, () => db.run(`DELETE FROM Event WHERE id = ?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export function eventsNetByAccount(accountId, assetId, untilIso) {
const r = db.one(`
SELECT COALESCE(SUM(
CASE
WHEN type = 'buy' AND toAccountId = ?1 THEN amount
WHEN type = 'sell' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'transfer' AND toAccountId = ?1 THEN amount
WHEN type = 'transfer' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'interest' AND toAccountId = ?1 THEN amount
ELSE 0
END
- (CASE WHEN type = 'transfer' AND fromAccountId = ?1 AND toAccountId = ?1 THEN amount + COALESCE(unitFee,0) ELSE 0 END)
), 0) AS net
FROM Event WHERE assetId = ?2${untilIso ? ' AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.net;
}
export function eventsCountByAccount(accountId, assetId, untilIso) {
const r = db.one(
`SELECT COUNT(*) AS n FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
${untilIso ? 'AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.n;
}
export function eventAssetIdsByAccount(accountId) {
return db.all(
`SELECT DISTINCT assetId FROM Event WHERE (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' ORDER BY assetId`,
[accountId]).map(r => r.assetId);
}
export function eventAccountIdsByAsset(assetId) {
return db.all(
`SELECT fromAccountId AS accountId FROM Event WHERE assetId = ?1 AND fromAccountId IS NOT NULL AND type <> 'reservation'
UNION
SELECT toAccountId AS accountId FROM Event WHERE assetId = ?1 AND toAccountId IS NOT NULL AND type <> 'reservation'
ORDER BY accountId`,
[assetId]).map(r => r.accountId);
}
export function eventDaysByAccount(accountId, assetId, vanIso, totIso) {
return db.all(
`SELECT DISTINCT substr(occurredAt, 1, 10) AS dag FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
AND substr(occurredAt, 1, 10) > ?3 AND substr(occurredAt, 1, 10) < ?4
ORDER BY dag`, [accountId, assetId, vanIso, totIso]).map(r => r.dag);
}
export function assetPositions(untilIso, { ownOnly = false } = {}) {
return db.all(`
SELECT a.id, a.symbol, a.name, a.kind, a.decimals, a.unit, a.subunit,
a.displayUnit,
COALESCE(SUM(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -(e.amount + COALESCE(e.unitFee,0))
WHEN e.type = 'interest' AND e.toIn THEN e.amount
WHEN e.type = 'transfer' THEN
(CASE WHEN e.toAccountId IS NOT NULL AND e.toIn THEN e.amount ELSE 0 END)
- (CASE WHEN e.fromAccountId IS NOT NULL AND e.fromIn THEN e.amount + COALESCE(e.unitFee,0) ELSE 0 END)
ELSE 0 END), 0) AS net,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * e.fiatPrice ELSE 0 END), 0) AS buy_spot_raw,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_fee_total,
COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN
(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -e.amount
-- A ONE-SIDED TRANSFER without consideration is a gift too,
-- as listTradeEvents already treats it; without this its units took the average
-- purchase price as cost. Two sides is internal and stays out.
WHEN e.type = 'transfer' AND NOT (e.fromAccountId IS NOT NULL AND e.fromIn)
AND e.toAccountId IS NOT NULL AND e.toIn THEN e.amount
WHEN e.type = 'transfer' AND NOT (e.toAccountId IS NOT NULL AND e.toIn)
AND e.fromAccountId IS NOT NULL AND e.fromIn THEN -e.amount
ELSE 0 END)
ELSE 0 END), 0) AS gift_amount,
COALESCE(SUM(CASE WHEN e.type = 'sell' AND e.fromIn THEN 1 ELSE 0 END), 0) AS sell_events
FROM Asset a
LEFT JOIN (
SELECT Event.*,
(?2 = 0 OR toAccount.wealthScope IS NULL) AS toIn,
(?2 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn
FROM Event
LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId
LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId
WHERE ?1 IS NULL OR Event.occurredAt <= ?1
) e ON e.assetId = a.id
GROUP BY a.id
ORDER BY a.symbol`, [untilIso ?? null, ownOnly ? 1 : 0]);
}
export function listTradeEvents(fromIso, untilIso) {
return db.all(`SELECT e.type, e.assetId, e.amount, e.fiatPrice, e.fiatFee, e.priceOrigin, e.occurredAt,
e.fromAccountId, e.toAccountId,
a.decimals
FROM Event e JOIN Asset a ON a.id = e.assetId
WHERE (e.type IN ('buy','sell')
-- EVERY one-sided transfer: value entering or leaving the books. With 'none' a
-- gift, without it a deposit or withdrawal at the day price.
OR (e.type = 'transfer' AND (e.fromAccountId IS NULL) <> (e.toAccountId IS NULL))
-- A EURO FEE ON A TRANSFER is money in that is gone, as with a purchase (plan
-- Bewijzen B2): the caller counts the fee and not the units.
OR (e.type = 'transfer' AND e.fromAccountId IS NOT NULL AND e.fiatFee > 0))
AND (?1 IS NULL OR e.occurredAt > ?1) AND e.occurredAt <= ?2`,
[fromIso, untilIso]);
}
export function listFlowLegs(fromIso, untilIso) {
const venster = `(?1 IS NULL OR Event.occurredAt > ?1) AND Event.occurredAt <= ?2`;
const been = (type, kolom, richting) => `
SELECT Event.${kolom} AS accountId, Event.assetId, Asset.decimals,
Event.type, '${richting}' AS direction, Event.amount AS units,
Event.fiatPrice, Event.fiatFee, Event.priceOrigin, Event.occurredAt
FROM Event JOIN Asset ON Asset.id = Event.assetId
WHERE Event.type = '${type}' AND Event.${kolom} IS NOT NULL AND ${venster}`;
return db.all([
been('buy', 'toAccountId', 'in'),
been('sell', 'fromAccountId', 'out'),
been('transfer', 'toAccountId', 'in'),
been('transfer', 'fromAccountId', 'out'),
].join(' UNION ALL '), [fromIso, untilIso]);
}
export function firstTaxEventDate() {
const r = db.one(`SELECT MIN(occurredAt) AS first FROM Event WHERE type <> 'reservation'`);
return r && r.first ? r.first : null;
}