source: app/(app)/add/actions.ts@ 95953b2

nextjs
Last change on this file since 95953b2 was 8b31e77, checked in by Vasilaki Tocili <vasilakigorgi@…>, 3 months ago

fix: history amount doubling, dashboard note prefix, unsaved custom tags

  • Fix history query inflating net_amount when transactions have multiple tags by restructuring into a CTE that computes amounts from breakdowns first, then joins tags separately (prevents cross-product duplication)
  • Exclude note:-prefixed tags from dashboard primary_tag selection so notes no longer appear with the raw prefix in Transaction History
  • Capture pending tag input on form submit via hidden field so custom tags typed without pressing Enter are still saved to the transaction
  • Property mode set to 100644
File size: 9.8 KB
RevLine 
[8563782]1'use server';
2
3import { auth } from '@/auth';
4import { redirect } from 'next/navigation';
5import { sql } from '@/app/lib/db';
6import { revalidatePath } from 'next/cache';
7
8// ─── Shared helpers ────────────────────────────────────────────
9
10export type ActionState = {
11 error?: string;
12 success?: string;
13} | undefined;
14
15async function requireUserId(): Promise<number> {
16 const session = await auth();
17 if (!session?.user?.id) {
18 redirect('/login');
19 }
20 const userId = Number(session.user.id);
21 if (!Number.isInteger(userId)) {
22 redirect('/login');
23 }
24 return userId;
25}
26
27// ─── Add Transaction Account ───────────────────────────────────
28
29export async function addTransactionAccount(
30 _prev: ActionState,
31 formData: FormData,
32): Promise<ActionState> {
33 const userId = await requireUserId();
34
35 const name = String(formData.get('name') ?? '').trim();
36 const balanceRaw = String(formData.get('balance') ?? '0').trim();
37
38 if (!name) {
39 return { error: 'Account name is required.' };
40 }
41
42 const balance = Number(balanceRaw);
43 if (Number.isNaN(balance)) {
44 return { error: 'Balance must be a number.' };
45 }
46
47 try {
48 await sql`
49 INSERT INTO transaction_account (account_name, balance, user_id)
50 VALUES (${name}, ${balance}, ${userId})
51 `;
52 } catch {
53 return { error: 'Failed to create account.' };
54 }
55
56 revalidatePath('/dashboard');
57 revalidatePath('/add');
58 return { success: `Account "${name}" created.` };
59}
60
61// ─── Add Tag ───────────────────────────────────────────────────
62
63export async function addTag(
64 _prev: ActionState,
65 formData: FormData,
66): Promise<ActionState> {
67 await requireUserId(); // auth guard only
68
69 const name = String(formData.get('name') ?? '').trim().toLowerCase();
70
71 if (!name) {
72 return { error: 'Tag name is required.' };
73 }
74 if (name.startsWith('__note:')) {
75 return { error: 'Reserved tag prefix.' };
76 }
77
78 // Check duplicate (case-insensitive)
79 const existing = await sql`
80 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${name}
81 `;
82 if (existing.length > 0) {
83 return { error: `Tag "${name}" already exists.` };
84 }
85
86 try {
87 await sql`INSERT INTO tag (tag_name) VALUES (${name})`;
88 } catch {
89 return { error: 'Failed to create tag.' };
90 }
91
92 revalidatePath('/add');
93 return { success: `Tag "${name}" created.` };
94}
95
96// ─── Add Transaction ───────────────────────────────────────────
97
98type BreakdownInput = {
99 type: 'from' | 'to';
100 accountId: number;
101 amount: number;
102};
103
104export async function addTransaction(
105 _prev: ActionState,
106 formData: FormData,
107): Promise<ActionState> {
108 const userId = await requireUserId();
109
110 const name = String(formData.get('name') ?? '').trim();
111 const date = String(formData.get('date') ?? '').trim();
112 const amountRaw = String(formData.get('amount') ?? '').trim();
113 const tagsJson = String(formData.get('tags') ?? '[]');
[8b31e77]114 const pendingTag = String(formData.get('pendingTag') ?? '').trim().toLowerCase();
[8563782]115 const note = String(formData.get('note') ?? '').trim();
116 const breakdownsJson = String(formData.get('breakdowns') ?? '[]');
117
118 // ── Validation ──
119 if (!name) {
120 return { error: 'Transaction name is required.' };
121 }
122 if (!date) {
123 return { error: 'Date is required.' };
124 }
125
126 const amount = Number(amountRaw);
127 if (!amountRaw || Number.isNaN(amount)) {
128 return { error: 'Amount must be a number.' };
129 }
130
131 let tags: string[];
132 try {
133 tags = JSON.parse(tagsJson);
134 if (!Array.isArray(tags)) {
135 throw new Error();
136 }
137 } catch {
138 return { error: 'Invalid tags data.' };
139 }
140
[8b31e77]141 // Include any tag the user typed but didn't press Enter for
142 if (pendingTag && !pendingTag.startsWith('__note:') && !tags.includes(pendingTag)) {
143 tags.push(pendingTag);
144 }
145
[8563782]146 let breakdowns: BreakdownInput[];
147 try {
148 breakdowns = JSON.parse(breakdownsJson);
149 if (!Array.isArray(breakdowns)) {
150 throw new Error();
151 }
152 } catch {
153 return { error: 'Invalid breakdowns data.' };
154 }
155
156 // Validate each breakdown
157 for (const breakdown of breakdowns) {
158 if (!['from', 'to'].includes(breakdown.type)) {
159 return { error: 'Invalid breakdown type.' };
160 }
161 if (!Number.isFinite(breakdown.amount) || breakdown.amount <= 0) {
162 return { error: 'Breakdown amounts must be positive.' };
163 }
164 if (!Number.isInteger(breakdown.accountId)) {
165 return { error: 'Invalid account in breakdown.' };
166 }
167 }
168
169 // Verify all accounts belong to this user
170 if (breakdowns.length > 0) {
171 const accountIds = [...new Set(breakdowns.map((b) => b.accountId))];
172 const ownedAccounts = await sql`
173 SELECT transaction_account_id
174 FROM transaction_account
175 WHERE user_id = ${userId}
176 AND transaction_account_id = ANY(${accountIds}::int[])
177 `;
178 if (ownedAccounts.length !== accountIds.length) {
179 return { error: 'One or more accounts do not belong to you.' };
180 }
181 }
182
183 // Compute net_amount from breakdowns
184 let totalEarned = 0;
185 let totalSpent = 0;
186 for (const breakdown of breakdowns) {
187 if (breakdown.type === 'to') {
188 totalEarned += breakdown.amount;
189 }
190 else {
191 totalSpent += breakdown.amount;
192 }
193 }
194 const netAmount = totalEarned - totalSpent;
195
196 try {
197 // Use a SQL transaction for atomicity
198 // eslint-disable-next-line @typescript-eslint/no-explicit-any
199 await sql.begin(async (tx: any) => {
200 // 1. Insert transaction
201 const [txRow] = await tx`
202 INSERT INTO transaction (transaction_name, amount, net_amount, date)
203 VALUES (${name}, ${amount}, ${netAmount}, ${date})
204 RETURNING transaction_id
205 `;
206 const transactionId: number = txRow.transaction_id;
207
208 // 2. Insert breakdowns & update account balances
209 for (const breakdown of breakdowns) {
210 const spent = breakdown.type === 'from' ? breakdown.amount : 0;
211 const earned = breakdown.type === 'to' ? breakdown.amount : 0;
212
213 await tx`
214 INSERT INTO transaction_breakdown
215 (transaction_id, transaction_account_id, spent_amount, earned_amount)
216 VALUES (${transactionId}, ${breakdown.accountId}, ${spent}, ${earned})
217 `;
218
219 // Update balance: earned increases, spent decreases
220 await tx`
221 UPDATE transaction_account
222 SET balance = balance + ${earned} - ${spent}
223 WHERE transaction_account_id = ${breakdown.accountId}
224 `;
225 }
226
227 // 3. Handle tags
228 for (const tagName of tags) {
229 const trimmed = tagName.trim().toLowerCase();
230 if (!trimmed || trimmed.startsWith('__note:')) {
231 continue;
232 }
233
234 // Find or insert tag
235 const existing = await tx`
236 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${trimmed}
237 `;
238 let tagId: number;
239 if (existing.length > 0) {
240 tagId = existing[0].tag_id;
241 } else {
242 const [inserted] = await tx`
243 INSERT INTO tag (tag_name) VALUES (${trimmed})
244 RETURNING tag_id
245 `;
246 tagId = inserted.tag_id;
247 }
248
249 // Check if assignment already exists
250 const existingAssignment = await tx`
251 SELECT tag_assigned_to_transaction_id
252 FROM tag_assigned_to_transaction
253 WHERE transaction_id = ${transactionId} AND tag_id = ${tagId}
254 `;
255 if (existingAssignment.length === 0) {
256 await tx`
257 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
258 VALUES (${transactionId}, ${tagId})
259 `;
260 }
261 }
262
263 // 4. Handle note as a special __note: tag
264 if (note) {
265 const noteTagName = `__note:${note}`;
266
267 const existingNote = await tx`
268 SELECT tag_id FROM tag WHERE tag_name = ${noteTagName}
269 `;
270 let noteTagId: number;
271 if (existingNote.length > 0) {
272 noteTagId = existingNote[0].tag_id;
273 } else {
274 const [inserted] = await tx`
275 INSERT INTO tag (tag_name) VALUES (${noteTagName})
276 RETURNING tag_id
277 `;
278 noteTagId = inserted.tag_id;
279 }
280
281 await tx`
282 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
283 VALUES (${transactionId}, ${noteTagId})
284 `;
285 }
286 });
287 } catch (e) {
288 console.error('addTransaction error:', e);
289 return { error: 'Failed to create transaction.' };
290 }
291
292 revalidatePath('/dashboard');
293 revalidatePath('/history');
294 revalidatePath('/add');
295 return { success: `Transaction "${name}" created.` };
296}
Note: See TracBrowser for help on using the repository browser.