Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Generate Prisma Client
run: npx prisma generate

- name: Run linters
run: yarn lint # Ensure code style and quality

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ test.db-journal

# Ignore Prisma generated client artifacts
prisma/node_modules/
tsconfig.tsbuildinfo
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,8 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/):
```bash
yarn test
```
- Run end-to-end (e2e) tests with Playwright:
```bash
yarn test:e2e
```
- **Automated CI:** All pull requests and pushes to `main` automatically trigger a GitHub Actions workflow. This workflow runs linters (`yarn lint`), builds the project (`yarn build`), and executes the test suite (`yarn test`) across multiple Node.js versions. Ensure these checks pass before merging.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A web app for managing a virtual "ledger" for your children. Making money fun (f
- Automatic monthly interest calculation
- Automatic weekly allowance
- Mobile-friendly display (can be added as a home icon)
- No email or personal info required; all account keys are derived from username+password using sha256

## Development

Expand Down Expand Up @@ -59,6 +60,17 @@ yarn dev

Open [http://localhost:3000](http://localhost:3000) to view the app.

### Testing

- Run unit tests:
```bash
yarn test
```
- Run end-to-end (e2e) tests with Playwright:
```bash
yarn test:e2e
```

## Code Quality

This project uses ESLint and Prettier for code linting and formatting. These checks are enforced automatically before each commit using Husky and lint-staged.
Expand Down
46 changes: 46 additions & 0 deletions e2e/transaction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test'
import sha256 from 'sha256'

test('can create a transaction and see it in the UI', async ({ page, baseURL }) => {
// Create a new user (reuse logic from create-account.spec.ts)
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 20000 })
await page.waitForSelector('button[aria-controls="create-form"]', { state: 'visible' })
await page.click('button[aria-controls="create-form"]')
await page.waitForSelector('div#create-form form', { state: 'visible' })

const randomSuffix = Math.floor(Math.random() * 1000000)
.toString()
.padStart(6, '0')
const username = `txnuser${randomSuffix}`
const password = 'txnPass123'

await page.fill('div#create-form input[name="user"]', username)
await page.fill('div#create-form input[name="pass"]', password)
await page.fill('div#create-form input[name="pass2"]', password)

// Wait for submit button to be enabled before clicking
const submitButtonLocator = page.locator('div#create-form button[type="submit"]')
await expect(submitButtonLocator).toBeEnabled()
await submitButtonLocator.click()
await page.waitForResponse((r) => r.url().includes('/api/create') && r.status() === 201)

const expectedKey = sha256(`${username}&&${password}`)
await page.waitForURL(`${baseURL}/manage/${expectedKey}`, { timeout: 10000 })
expect(page.url()).toBe(`${baseURL}/manage/${expectedKey}`)

// Add a transaction
await page.waitForSelector('form[action^="/api/manage/"]', { state: 'visible' })
await page.fill('form[action^="/api/manage/"] input[name="description"]', 'Test Transaction')
await page.fill('form[action^="/api/manage/"] input[name="amount"]', '42')
await page.click('form[action^="/api/manage/"] button[type="submit"]')

// Assert we are redirected back to the manage page (not a JSON dump)
await page.waitForURL(`${baseURL}/manage/${expectedKey}`)
// The UI should still be visible (not a JSON response)
await expect(page.getByRole('heading', { name: /Account Balance/i })).toBeVisible()
// Wait for transaction to appear in the UI
const txnRow = await page.locator('text=Test Transaction').first()
expect(await txnRow.isVisible()).toBeTruthy()
const valueCell = await txnRow.locator('..').locator('text=42')
expect(await valueCell.isVisible()).toBeTruthy()
})
43 changes: 43 additions & 0 deletions e2e/update-account.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test'
import sha256 from 'sha256'

test('can update interest and allowance and get redirected', async ({ page, baseURL }) => {
// Create a new user
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 20000 })
await page.waitForSelector('button[aria-controls="create-form"]', { state: 'visible' })
await page.click('button[aria-controls="create-form"]')
await page.waitForSelector('div#create-form form', { state: 'visible' })

const randomSuffix = Math.floor(Math.random() * 1000000)
.toString()
.padStart(6, '0')
const username = `updateuser${randomSuffix}`
const password = 'updatePass123'

await page.fill('div#create-form input[name="user"]', username)
await page.fill('div#create-form input[name="pass"]', password)
await page.fill('div#create-form input[name="pass2"]', password)

const submitButtonLocator = page.locator('div#create-form button[type="submit"]')
await expect(submitButtonLocator).toBeEnabled()
await submitButtonLocator.click()
await page.waitForResponse((r) => r.url().includes('/api/create') && r.status() === 201)

const expectedKey = sha256(`${username}&&${password}`)
await page.waitForURL(`${baseURL}/manage/${expectedKey}`, { timeout: 10000 })
expect(page.url()).toBe(`${baseURL}/manage/${expectedKey}`)

// Update interest and allowance
await page.waitForSelector('form[action^="/api/manage/"][method="POST"]', { state: 'visible' })
await page.fill('input[name="interest"]', '0.123')
await page.fill('input[name="allowance"]', '9.87')
await page.click('form[action^="/api/manage/"][method="POST"] button[type="submit"]')

// Assert we are redirected back to the manage page (not a JSON dump)
await page.waitForURL(`${baseURL}/manage/${expectedKey}`)
// The UI should still be visible (not a JSON response)
await expect(page.getByRole('heading', { name: /Account Balance/i })).toBeVisible()
// Check that the new interest and allowance values are displayed
await expect(page.locator('input[name="interest"]')).toHaveValue('0.123')
await expect(page.locator('input[name="allowance"]')).toHaveValue('9.870')
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@prisma/client": "6.6.0",
"bootstrap": "^5.3.2",
"dotenv": "^16.5.0",
"moment": "^2.29.4",
"moment": "^2.30.1",
"next": "^13.4.0",
"pg": "^8.11.1",
"react": "^18.2.0",
Expand Down
16 changes: 10 additions & 6 deletions pages/api/manage/[userid]/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ export default async function handler(req: TransactionRequest, res: NextApiRespo
return
}
// Use Prisma to add transaction
let amount = typeof amtRaw === 'string' ? parseFloat(amtRaw) : amtRaw
if (action === 'subtract' && amount > 0) {
amount = -amount
} else if (amount < 0) {
let amount = Math.abs(typeof amtRaw === 'string' ? parseFloat(amtRaw) : amtRaw)
if (action === 'subtract') {
amount = -amount
}
// Attempt to create the transaction
try {
const result = await transactionService.addTransaction(userid, description, amount)
return res.status(201).json({ message: 'Transaction created', result })
await transactionService.addTransaction(userid, description, amount)
// If the request is a form POST, redirect to the manage page
if (req.method === 'POST') {
res.redirect(303, `/manage/${userid}`)
return
}
// For non-form requests, return JSON (for API/AJAX use)
return res.status(201).json({ message: 'Transaction created' })
} catch (error) {
// Log the error for debugging
console.error('Failed to create transaction:', error)
Expand Down
10 changes: 8 additions & 2 deletions pages/api/manage/[userid]/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ export default async function handler(req: UpdateRequest, res: NextApiResponse)
const interest = parseFloat(interestRaw)
const allowance = parseFloat(allowanceRaw)
try {
const result = await accountService.updateAccountSettings(userid, interest, allowance)
return res.status(200).json({ message: 'User info updated', result })
await accountService.updateAccountSettings(userid, interest, allowance)
// If the request is a form POST, redirect to the manage page
if (req.method === 'POST') {
res.redirect(303, `/manage/${userid}`)
return
}
// For non-form requests, return JSON (for API/AJAX use)
return res.status(200).json({ message: 'User info updated' })
} catch (error) {
// Log the error for debugging
console.error('Failed to update user info:', error)
Expand Down
2 changes: 0 additions & 2 deletions prisma/generated/postgresql.prisma
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
generator client {
provider = "prisma-client-js"

output = "../node_modules/@prisma/client"
}

datasource db {
Expand Down
2 changes: 0 additions & 2 deletions prisma/generated/sqlite.prisma
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
generator client {
provider = "prisma-client-js"

output = "../node_modules/@prisma/client"
}

datasource db {
Expand Down
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/@prisma/client" // Explicitly set output path
output = "../node_modules/.prisma/client" // Explicitly set output path for CI safety
}

datasource db {
Expand Down
9 changes: 2 additions & 7 deletions scripts/build-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,9 @@ providers.forEach(({ name, wrapper }) => {
const modifiedWrapperContent = wrapperContent.replace(
generatorRegex,
(match, opening, content, closing) => {
if (content.includes('output')) {
return match; // Already has output, do nothing
}
// Add output path, ensuring proper indentation and newline before closing brace
const indentedOutput = '\n output = "../node_modules/@prisma/client"';
// Ensure content ends with a newline if it's not empty and trim existing whitespace
// Remove output path so Prisma uses the default (node_modules/@prisma/client)
const formattedContent = content.trim() + (content.trim() ? '\n' : '');
return opening + '\n' + formattedContent + indentedOutput + '\n' + closing.trim(); // Reconstruct with newlines
return opening + '\n' + formattedContent + closing.trim();
}
);
// -------------------------------------------------------
Expand Down
13 changes: 5 additions & 8 deletions src/services/db/financials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,10 @@ async function updateInterest(idOrView: string): Promise<void> {
next.setMonth(next.getMonth() + 1)
if (next > new Date()) return

// interest applied on sum at that time
await transactionService.addTransaction(
accountId,
'Interest payment',
acct.value * acct.interest,
true,
next,
)
const interestValue = acct.value * acct.interest
if (!Number.isFinite(interestValue) || interestValue === 0) return

await transactionService.addTransaction(accountId, 'Interest payment', interestValue, true, next)

// recurse to catch up multiple months
return updateInterest(idOrView)
Expand Down Expand Up @@ -68,6 +64,7 @@ async function updateAllowance(idOrView: string): Promise<void> {
}

if (next > new Date()) return
if (!Number.isFinite(acct.allowance) || acct.allowance === 0) return

await transactionService.addTransaction(accountId, 'Allowance', acct.allowance, false, next, true)

Expand Down
5 changes: 3 additions & 2 deletions src/services/db/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Transaction } from '@prisma/client'
import prisma from './client'

/**
Expand Down Expand Up @@ -45,10 +46,10 @@ async function getTransactions(
orderBy: { ts: 'desc' },
take: 20,
})
return trs.map((t) => ({
return trs.map((t: Transaction) => ({
id: t.id,
description: t.description,
ts: Math.floor(t.ts.getTime() / 1000),
ts: t.ts instanceof Date ? Math.floor(t.ts.getTime() / 1000) : t.ts,
value: t.value,
}))
}
Expand Down
1 change: 0 additions & 1 deletion tsconfig.tsbuildinfo

This file was deleted.

4 changes: 2 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4510,9 +4510,9 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==

moment@^2.29.4:
moment@^2.30.1:
version "2.30.1"
resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==

ms@^2.0.0, ms@^2.1.1, ms@^2.1.3:
Expand Down