Skip to content

Commit 4a1c929

Browse files
authored
fix(web): route chat expense writes through the server endpoint (ws-10) (#3516)
1 parent 838b103 commit 4a1c929

8 files changed

Lines changed: 402 additions & 33 deletions

File tree

apps/web/src/core/lib/chatActions/serverActions.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@ import type { ChatAction } from "./types";
2222
* - empty query → не дзвонимо мережу.
2323
*/
2424

25-
const apiUrlMock = vi.fn((p: string) => `https://srv.test${p}`);
26-
vi.mock("../../../shared/lib/api/apiUrl", () => ({
27-
apiUrl: (p: string) => apiUrlMock(p),
28-
}));
25+
// `vi.hoisted` — бо `serverActions.ts` тепер тягне `shared/api`, який кличе
26+
// `apiUrl("")` ще на module-init (до тіла цього файлу); звичайний const
27+
// упирався б у TDZ всередині hoisted-фабрики vi.mock.
28+
const apiUrlMock = vi.hoisted(() =>
29+
vi.fn((p: string) => `https://srv.test${p}`),
30+
);
31+
vi.mock("../../../shared/lib/api/apiUrl", async (importOriginal) => {
32+
const actual =
33+
await importOriginal<typeof import("../../../shared/lib/api/apiUrl")>();
34+
return { ...actual, apiUrl: (p: string) => apiUrlMock(p) };
35+
});
2936

3037
const fetchMock = vi.fn();
3138

apps/web/src/core/lib/chatActions/serverActions.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,26 @@
1717
*/
1818

1919
import { apiUrl } from "../../../shared/lib/api/apiUrl";
20+
import { apiClient } from "../../../shared/api";
21+
import { resolveExpenseCategoryMeta } from "../../../modules/finyk/utils";
22+
import {
23+
flushPendingWrites,
24+
getCategories,
25+
getTransactions,
26+
saveTransactions,
27+
} from "../../../modules/finyk/lib/finykStorage";
28+
import { createTransaction as createTransactionLocal } from "./finykActions/transactions";
29+
import type { Transaction } from "@sergeant/finyk-domain/domain/types";
2030
import type {
2131
RecallMemoryRequest,
2232
RecallMemoryResponse,
2333
} from "@sergeant/shared";
24-
import type { ChatAction, ChatActionResult, RecallMemoryAction } from "./types";
34+
import type {
35+
ChatAction,
36+
ChatActionResult,
37+
CreateTransactionAction,
38+
RecallMemoryAction,
39+
} from "./types";
2540

2641
/**
2742
* Кількість мс, яку клієнт чекає на відповідь recall перш ніж скасувати
@@ -133,19 +148,93 @@ async function handleRecallMemory(action: RecallMemoryAction): Promise<string> {
133148
return formatRecallResults(trimmedQuery, out.memories);
134149
}
135150

151+
/**
152+
* `create_transaction` — витрати йдуть через `POST /api/finyk/manual-expenses`
153+
* (server-of-record + локальне LS-дзеркало для миттєвого UI), доходи та
154+
* offline-fallback лишаються на legacy LS-обробнику.
155+
*
156+
* Server-шлях не дає undo: DELETE-ендпоінта для manual-expenses ще немає,
157+
* тож «скасувати» означало б розсинхрон LS ↔ DB. Fallback-шлях зберігає
158+
* undo від `createTransactionLocal`.
159+
*/
160+
async function handleCreateTransaction(
161+
action: CreateTransactionAction,
162+
): Promise<ChatActionResult> {
163+
const { type, amount, category, description, date } = action.input;
164+
const amt = Number(amount);
165+
if (!Number.isFinite(amt) || amt <= 0) {
166+
return "Некоректна сума транзакції.";
167+
}
168+
// Income сервер не приймає (manual-expenses — лише витрати) — пишемо локально.
169+
if (type === "income") {
170+
return createTransactionLocal(action);
171+
}
172+
try {
173+
const { expense } = await apiClient.finyk.createManualExpense({
174+
// LS та tool-input історично у гривнях; API — у копійках (Hard Rule #1).
175+
amount: Math.round(Math.abs(amt) * 100),
176+
category: category?.trim() || "other",
177+
...(description?.trim() ? { note: description.trim() } : {}),
178+
...(date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? { date } : {}),
179+
});
180+
// LS-дзеркало через канонічний finykStorage-wrapper: списки/статистика
181+
// читають blob синхронно, без рефетчу. `amount` — у ГРИВНЯХ і legacy-поле
182+
// `category` поруч із канонічним `categoryId`: blob історично тримає
183+
// shape із `finykActions/transactions.ts#createTransaction`, і його
184+
// читачі очікують саме його.
185+
const isoDate = new Date(`${expense.date}T12:00:00`).toISOString();
186+
const entry: Transaction & { category: string } = {
187+
id: expense.id,
188+
amount: Math.abs(amt),
189+
date: isoDate,
190+
categoryId: category?.trim() || "",
191+
category: category?.trim() || "",
192+
type: "expense",
193+
source: "ai",
194+
time: Math.floor(Date.parse(isoDate) / 1000),
195+
description: description?.trim() || "",
196+
mcc: 0,
197+
accountId: null,
198+
manual: true,
199+
_source: "ai",
200+
_accountId: null,
201+
_manual: true,
202+
};
203+
const manualExpenses = getTransactions();
204+
manualExpenses.unshift(entry);
205+
saveTransactions(manualExpenses);
206+
// saveTransactions — debounced; flush одразу, щоб запис не загубився
207+
// при швидкому закритті вкладки після відповіді чату.
208+
flushPendingWrites();
209+
const meta = category?.trim()
210+
? resolveExpenseCategoryMeta(category.trim(), getCategories())
211+
: undefined;
212+
const label = meta?.label || category?.trim() || "";
213+
return `Витрату ${amt} грн${description?.trim() ? ` "${description.trim()}"` : ""}${label ? ` (${label})` : ""} записано на сервері (id:${expense.id})`;
214+
} catch {
215+
// Мережа/401/5xx — не губимо запис: пишемо локально зі старим undo-шляхом.
216+
const local = createTransactionLocal(action);
217+
const suffix = " (сервер недоступний — записано лише локально)";
218+
if (typeof local === "string") return local + suffix;
219+
return { ...local, result: local.result + suffix };
220+
}
221+
}
222+
136223
/**
137224
* Async dispatcher — повертає результат, якщо action — "server-side" tool,
138225
* інакше `undefined` (sync-flow обробить решту).
139226
*
140-
* Зберігаємо строкову форму `ChatActionResult` (без undo): recall — read-only,
141-
* undo не потрібен.
227+
* `recall_memory` — read-only, undo не потрібен. `create_transaction` —
228+
* server-write (undo лише у offline-fallback-а).
142229
*/
143230
export async function handleAsyncChatAction(
144231
action: ChatAction,
145232
): Promise<ChatActionResult | undefined> {
146233
switch (action.name) {
147234
case "recall_memory":
148235
return handleRecallMemory(action as RecallMemoryAction);
236+
case "create_transaction":
237+
return handleCreateTransaction(action as CreateTransactionAction);
149238
default:
150239
return undefined;
151240
}
@@ -157,4 +246,5 @@ export async function handleAsyncChatAction(
157246
*/
158247
export const ASYNC_CHAT_ACTION_NAMES: ReadonlySet<string> = new Set([
159248
"recall_memory",
249+
"create_transaction",
160250
]);

apps/web/src/core/lib/hubChatActions.test.ts

Lines changed: 115 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ afterEach(() => {
2525
clearSqliteCompletionsCache();
2626
clearSqliteRoutineStateCache();
2727
vi.useRealTimers();
28+
vi.unstubAllGlobals();
2829
});
2930

3031
function readLS<T>(key: string, fallback: T): T {
@@ -74,34 +75,109 @@ describe("create_habit", () => {
7475
});
7576
});
7677

78+
// ws-10: create_transaction — async/server tool (`ASYNC_CHAT_ACTION_NAMES`).
79+
// Витрати йдуть через `POST /api/finyk/manual-expenses`; offline-fallback та
80+
// доходи лишаються на legacy LS-шляху. Тому всі тести — через `executeActions`.
7781
describe("create_transaction", () => {
78-
it("записує витрату в finyk_manual_expenses_v1", () => {
79-
const msg = executeAction({
80-
name: "create_transaction",
81-
input: { amount: 150, category: "food", description: "кава" },
82-
});
83-
expect(msg).toContain("Витрату");
84-
expect(msg).toContain("150");
82+
function stubFetchReject(): void {
83+
vi.stubGlobal(
84+
"fetch",
85+
vi.fn(async () => {
86+
throw new TypeError("Failed to fetch");
87+
}),
88+
);
89+
}
90+
91+
afterEach(() => {
92+
vi.unstubAllGlobals();
93+
});
94+
95+
it("пише витрату через сервер і дзеркалить у finyk_manual_expenses_v1", async () => {
96+
const serverExpense = {
97+
id: "0b7e6c3a-7e0f-4b59-9b39-2f4f7f6f9d11",
98+
amountKopiykas: 15000,
99+
category: "food",
100+
date: "2024-06-15",
101+
note: "кава",
102+
createdAt: "2024-06-15T12:00:00.000Z",
103+
updatedAt: "2024-06-15T12:00:00.000Z",
104+
};
105+
vi.stubGlobal(
106+
"fetch",
107+
vi.fn(
108+
async () =>
109+
new Response(JSON.stringify({ ok: true, expense: serverExpense }), {
110+
status: 201,
111+
headers: { "content-type": "application/json" },
112+
}),
113+
),
114+
);
115+
116+
const [out] = await executeActions([
117+
{
118+
name: "create_transaction",
119+
input: { amount: 150, category: "food", description: "кава" },
120+
},
121+
]);
122+
expect(out!.result).toContain("Витрату");
123+
expect(out!.result).toContain("150");
124+
expect(out!.result).toContain("записано на сервері");
125+
expect(out!.result).toContain(serverExpense.id);
126+
// Server-шлях не дає undo — DELETE-ендпоінта немає.
127+
expect(out!.undo).toBeUndefined();
128+
85129
const arr = readLS<
86130
Array<{
131+
id: string;
87132
amount: number;
88133
category: string;
89134
description: string;
90135
type: string;
91136
}>
92137
>("finyk_manual_expenses_v1", []);
93138
expect(arr).toHaveLength(1);
139+
// LS-дзеркало: id серверний (UUID), amount у гривнях (legacy LS-shape).
140+
expect(arr[0]!.id).toBe(serverExpense.id);
94141
expect(arr[0]!.amount).toBe(150);
95142
expect(arr[0]!.category).toBe("food");
96143
expect(arr[0]!.type).toBe("expense");
97144
});
98145

99-
it("записує дохід коли type='income'", () => {
100-
const msg = executeAction({
101-
name: "create_transaction",
102-
input: { type: "income", amount: 5000 },
103-
});
104-
expect(msg).toContain("Дохід");
146+
it("fallback: пише локально з undo, коли сервер недоступний", async () => {
147+
stubFetchReject();
148+
const [out] = await executeActions([
149+
{
150+
name: "create_transaction",
151+
input: { amount: 150, category: "food", description: "кава" },
152+
},
153+
]);
154+
expect(out!.result).toContain("Витрату");
155+
expect(out!.result).toContain("150");
156+
expect(out!.result).toContain("записано лише локально");
157+
expect(typeof out!.undo).toBe("function");
158+
159+
const arr = readLS<Array<{ id: string; amount: number; type: string }>>(
160+
"finyk_manual_expenses_v1",
161+
[],
162+
);
163+
expect(arr).toHaveLength(1);
164+
expect(arr[0]!.id).toMatch(/^m_/);
165+
expect(arr[0]!.amount).toBe(150);
166+
expect(arr[0]!.type).toBe("expense");
167+
});
168+
169+
it("записує дохід локально коли type='income' (сервер приймає лише витрати)", async () => {
170+
stubFetchReject();
171+
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
172+
const [out] = await executeActions([
173+
{
174+
name: "create_transaction",
175+
input: { type: "income", amount: 5000 },
176+
},
177+
]);
178+
expect(out!.result).toContain("Дохід");
179+
// Income не має бити в API взагалі.
180+
expect(fetchMock).not.toHaveBeenCalled();
105181
const arr = readLS<Array<{ type: string; amount: number }>>(
106182
"finyk_manual_expenses_v1",
107183
[],
@@ -110,21 +186,26 @@ describe("create_transaction", () => {
110186
expect(arr[0]!.amount).toBe(5000);
111187
});
112188

113-
it("відмовляє на 0 або від'ємну суму", () => {
114-
expect(
115-
executeAction({
116-
name: "create_transaction",
117-
input: { amount: 0 },
118-
}),
119-
).toContain("Некоректна");
120-
expect(
121-
executeAction({
122-
name: "create_transaction",
123-
input: { amount: -5 },
124-
}),
125-
).toContain("Некоректна");
189+
it("відмовляє на 0 або від'ємну суму без серверного виклику", async () => {
190+
stubFetchReject();
191+
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
192+
const results = await executeActions([
193+
{ name: "create_transaction", input: { amount: 0 } },
194+
{ name: "create_transaction", input: { amount: -5 } },
195+
]);
196+
expect(results[0]!.result).toContain("Некоректна");
197+
expect(results[1]!.result).toContain("Некоректна");
198+
expect(fetchMock).not.toHaveBeenCalled();
126199
expect(localStorage.getItem("finyk_manual_expenses_v1")).toBeNull();
127200
});
201+
202+
it("sync executeAction відмовляє з інструкцією про async-шлях", () => {
203+
const msg = executeAction({
204+
name: "create_transaction",
205+
input: { amount: 150 },
206+
});
207+
expect(msg).toContain("вимагає async");
208+
});
128209
});
129210

130211
describe("log_set", () => {
@@ -231,6 +312,14 @@ describe("log_water", () => {
231312

232313
describe("executeActions — паралельне виконання", () => {
233314
it("повертає результати у тому ж порядку, що й input", async () => {
315+
// create_transaction — async/server tool: глушимо fetch, щоб тест
316+
// детерміновано пішов offline-fallback-шляхом без реальної мережі.
317+
vi.stubGlobal(
318+
"fetch",
319+
vi.fn(async () => {
320+
throw new TypeError("Failed to fetch");
321+
}),
322+
);
234323
const results = await executeActions([
235324
{ name: "create_habit", input: { name: "Пити воду" } },
236325
{

packages/api-client/src/createApiClient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
createBillingEndpoints,
4040
type BillingEndpoints,
4141
} from "./endpoints/billing";
42+
import { createFinykEndpoints, type FinykEndpoints } from "./endpoints/finyk";
4243
import {
4344
createWeeklyDigestEndpoints,
4445
type WeeklyDigestEndpoints,
@@ -78,6 +79,7 @@ export interface ApiClient {
7879
privat: PrivatEndpoints;
7980
waitlist: WaitlistEndpoints;
8081
billing: BillingEndpoints;
82+
finyk: FinykEndpoints;
8183
weeklyDigest: WeeklyDigestEndpoints;
8284
transcribe: TranscribeEndpoints;
8385
webVitals: WebVitalsEndpoints;
@@ -99,6 +101,7 @@ export function createApiClient(config: ApiClientConfig = {}): ApiClient {
99101
privat: createPrivatEndpoints(http),
100102
waitlist: createWaitlistEndpoints(http),
101103
billing: createBillingEndpoints(http),
104+
finyk: createFinykEndpoints(http),
102105
weeklyDigest: createWeeklyDigestEndpoints(http),
103106
transcribe: createTranscribeEndpoints(http),
104107
webVitals: createWebVitalsEndpoints(http),

0 commit comments

Comments
 (0)