ci / lint-test (push) Successful in 52s
A rule flagged manual_only never runs in the apply-all pass; instead it shows as a button in the transactions bulk bar and applies its actions to the current selection (conditions ignored — the selection is the condition). Recorded as a rule_apply_run, so it reverts from Rules -> Apply History like any other run. Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every time. Now it is one click, and any other combo can be defined the same way. Extracts the action-application and snapshot logic from the apply route into src/lib/rule-actions.ts so both callers share one implementation — splits upsert rather than delete+reinsert, so settled flags survive.
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
/**
|
|
* Applying a rule's `actions` to transactions, and snapshotting the before-state
|
|
* so a run can be reverted.
|
|
*
|
|
* Shared by the two callers that write rule actions:
|
|
* - `POST /api/rules/apply` — condition-matched bulk run
|
|
* - `POST /api/transactions/bulk` with `action: "apply_rule"` — quick action
|
|
* fired against a hand-picked selection (conditions ignored)
|
|
*/
|
|
|
|
import { queryRaw } from "@/lib/db";
|
|
import type { Actions } from "@/lib/rules";
|
|
|
|
export interface SnapshotEntry {
|
|
transaction_id: number;
|
|
had_override: boolean;
|
|
prev_category_override: string | null;
|
|
prev_merchant_normalized: string | null;
|
|
prev_tag_ids: number[];
|
|
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
|
}
|
|
|
|
/**
|
|
* Apply `actions` to one transaction.
|
|
*
|
|
* Tags are additive (ON CONFLICT DO NOTHING) — re-running never duplicates and
|
|
* never removes tags the rule doesn't mention. Splits replace the participant
|
|
* set but upsert shares, so `settled` flags survive a re-run; a plain
|
|
* delete+reinsert would silently un-settle reconciled splits.
|
|
*/
|
|
export async function applyRuleActions(
|
|
transactionId: number,
|
|
actions: Actions,
|
|
opts: { skipSplit?: boolean } = {}
|
|
): Promise<void> {
|
|
if (actions.set_category || actions.set_merchant) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
|
category_override = COALESCE($2, transaction_overrides.category_override),
|
|
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
|
updated_at = NOW()`,
|
|
[transactionId, actions.set_category || null, actions.set_merchant || null]
|
|
);
|
|
}
|
|
|
|
if (actions.add_tag_ids?.length) {
|
|
for (const tagId of actions.add_tag_ids) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
[transactionId, tagId]
|
|
);
|
|
}
|
|
}
|
|
|
|
if (actions.apply_split?.length && !opts.skipSplit) {
|
|
await queryRaw(
|
|
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
|
|
[transactionId, actions.apply_split.map((s) => s.participant_id)]
|
|
);
|
|
for (const s of actions.apply_split) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (transaction_id, participant_id)
|
|
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
|
[transactionId, s.participant_id, s.share_percent]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Capture the overrides / tags / splits of `ids` before a run mutates them. */
|
|
export async function captureSnapshot(ids: number[]): Promise<SnapshotEntry[]> {
|
|
if (ids.length === 0) return [];
|
|
|
|
const overrides = await queryRaw<{
|
|
transaction_id: number;
|
|
category_override: string | null;
|
|
merchant_normalized: string | null;
|
|
}>(
|
|
`SELECT transaction_id, category_override, merchant_normalized
|
|
FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
|
|
|
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
|
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const tagMap = new Map<number, number[]>();
|
|
for (const row of tagRows) {
|
|
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
|
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
|
}
|
|
|
|
const splitRows = await queryRaw<{
|
|
transaction_id: number;
|
|
participant_id: number;
|
|
share_percent: number;
|
|
settled: boolean;
|
|
}>(
|
|
`SELECT transaction_id, participant_id, share_percent, settled
|
|
FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
|
for (const row of splitRows) {
|
|
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
|
splitMap.get(row.transaction_id)!.push({
|
|
participant_id: row.participant_id,
|
|
share_percent: row.share_percent,
|
|
settled: row.settled,
|
|
});
|
|
}
|
|
|
|
return ids.map((id) => {
|
|
const ov = overrideMap.get(id);
|
|
return {
|
|
transaction_id: id,
|
|
had_override: !!ov,
|
|
prev_category_override: ov?.category_override ?? null,
|
|
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
|
prev_tag_ids: tagMap.get(id) ?? [],
|
|
prev_splits: splitMap.get(id) ?? [],
|
|
};
|
|
});
|
|
}
|