Add account sharing, Get Paid schedule, recurring manager, overspend warnings, mobile nav

Account sharing:
- fileStorage: add shares.json with createShare/listShares/getShareData/revokeShare
- /api/share/account: real implementation using session token
- /api/share/list: GET endpoint returning owned + received shares
- /api/share/data: GET endpoint to fetch a shared account's data
- SharedAccountManager: use session token, load existing shares, revoke support
- AccountManagement: render SharedAccountManager per-account
- page.tsx: load received shares on login, show "Shared with me" cards in Accounts view

Get Paid schedule:
- PaycheckSchedule type: weekly/biweekly/semimonthly/monthly with day config
- computeNextPayday(): calculates next payday from today
- Schedule UI in GetPaid modal with live "Next payday: ..." preview
- Schedule persisted to localStorage template

Recurring transaction manager:
- Fix biweekly support (was missing)
- Show "due now" vs "upcoming" with amber badge for overdue
- Render in Transactions view; onApplyRecurring creates new transactions + updates lastAppliedDate

Envelope overspending:
- Red warning banner at top of Envelopes view listing overspent envelopes
- Red ring on individual overspent envelope cards

Mobile layout:
- Fixed bottom tab bar (Home/Accounts/Txns/Envelopes/More) on sm: and below
- Active tab highlighted in blue
- Main content padded to avoid being hidden by bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 09:10:10 -04:00
1 parent 18fe6b5141
commit 5339e9818a
9 files changed
+518 -161

No files matched your search

+74 -1
View File
@@ -7,15 +7,27 @@ const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
const SHARES_FILE = path.join(DATA_DIR, 'shares.json');
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
interface Share {
ownerUserId: string;
ownerEmail: string;
targetEmail: string;
accountId: string;
role: 'view' | 'edit';
createdAt: string;
}
interface Shares { [shareId: string]: Share }
async function initializeDirectories() {
try {
await fsPromises.mkdir(DATA_DIR, { recursive: true });
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE]) {
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE]) {
try {
await fsPromises.access(file);
} catch {
@@ -268,6 +280,67 @@ export class FileStorageService {
}
}
static async createShare(ownerUserId: string, ownerEmail: string, targetEmail: string, accountId: string, role: 'view' | 'edit'): Promise<string> {
const users = await readJson<Users>(USERS_FILE);
if (!Object.values(users).some(u => u.email === targetEmail)) {
throw new Error('No Capsule account found for that email address');
}
const releaseLock = await FileLock.acquire('shares-file');
try {
const shares = await readJson<Shares>(SHARES_FILE);
// Prevent duplicate shares for same account+email
const existing = Object.entries(shares).find(
([, s]) => s.ownerUserId === ownerUserId && s.accountId === accountId && s.targetEmail === targetEmail
);
if (existing) return existing[0];
const shareId = crypto.randomUUID();
shares[shareId] = { ownerUserId, ownerEmail, targetEmail, accountId, role, createdAt: new Date().toISOString() };
await writeJson(SHARES_FILE, shares);
return shareId;
} finally {
releaseLock();
}
}
static async listShares(email: string, userId: string): Promise<{ received: Array<Share & { shareId: string }>; owned: Array<Share & { shareId: string }> }> {
const shares = await readJson<Shares>(SHARES_FILE);
const received = Object.entries(shares)
.filter(([, s]) => s.targetEmail === email)
.map(([shareId, s]) => ({ shareId, ...s }));
const owned = Object.entries(shares)
.filter(([, s]) => s.ownerUserId === userId)
.map(([shareId, s]) => ({ shareId, ...s }));
return { received, owned };
}
static async getShareData(shareId: string, requestorEmail: string): Promise<{ account: any; envelopes: any[]; transactions: any[] } | null> {
const shares = await readJson<Shares>(SHARES_FILE);
const share = shares[shareId];
if (!share || share.targetEmail !== requestorEmail) return null;
const data = await this.loadUserData(share.ownerUserId);
const account = data.accounts?.find((a: any) => a.id === share.accountId);
if (!account) return null;
const envelopes = (data.envelopes || []).filter((e: any) => e.accountId === share.accountId);
const transactions = (data.transactions || []).filter((t: any) => t.accountId === share.accountId);
return { account, envelopes, transactions };
}
static async revokeShare(shareId: string, requestorUserId: string): Promise<void> {
const releaseLock = await FileLock.acquire('shares-file');
try {
const shares = await readJson<Shares>(SHARES_FILE);
const share = shares[shareId];
if (!share) return;
if (share.ownerUserId !== requestorUserId && share.targetEmail !== requestorUserId) {
throw new Error('Not authorized to revoke this share');
}
delete shares[shareId];
await writeJson(SHARES_FILE, shares);
} finally {
releaseLock();
}
}
private static async _createSession(userId: string, email: string): Promise<string> {
const releaseLock = await FileLock.acquire('sessions-file');
try {