chore: convert to Turborepo + npm workspaces monorepo
- Move React/Vite frontend to apps/web/ (@budgetwise/web)
- Add apps/appwrite/ (@budgetwise/appwrite) to source-control the
Appwrite backend: declarative schema in appwrite.json (5 collections),
CLI-based deploy.sh for containerized use, functions/ dir for future
Appwrite Functions
- Add turbo.json for task orchestration (build, deploy, dev)
- Replace .gitlab-ci.yml with Woodpecker CI pipelines in .woodpecker/:
web-production.yml — push to main → build + rsync to prod
web-staging.yml — push to staging → build + rsync to staging
web-preview.yml — PR open → deploy to {pr}.{domain}; PR close → cleanup
appwrite.yml — schema changes in apps/appwrite/ → CLI deploy
- All secrets injected via Woodpecker CI (no committed .env files)
This commit is contained in:
205
apps/web/src/lib/calculations.ts
Normal file
205
apps/web/src/lib/calculations.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type {
|
||||
Income,
|
||||
BalanceSheet,
|
||||
Bucket,
|
||||
Debt,
|
||||
MonthlySnapshot,
|
||||
BucketAllocation,
|
||||
LoanAnalysis,
|
||||
DebtRepaymentPlan,
|
||||
InvestmentProjection,
|
||||
} from '../types';
|
||||
|
||||
// ─── Income ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function toMonthly(amount: number, frequency: 'monthly' | 'yearly'): number {
|
||||
return frequency === 'yearly' ? amount / 12 : amount;
|
||||
}
|
||||
|
||||
export function totalMonthlyIncome(incomes: Income[]): number {
|
||||
return incomes.reduce((sum, i) => sum + toMonthly(i.amount, i.frequency), 0);
|
||||
}
|
||||
|
||||
// ─── Buffer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function bufferAmount(sheet: BalanceSheet, monthlyIncome: number): number {
|
||||
if (sheet.buffer_type === 'percent') {
|
||||
return monthlyIncome * (sheet.buffer_value / 100);
|
||||
}
|
||||
return sheet.buffer_value;
|
||||
}
|
||||
|
||||
// ─── Bucket allocations ───────────────────────────────────────────────────────
|
||||
|
||||
export function bucketMonthlyAllocation(bucket: Bucket, monthlyIncome: number): number {
|
||||
if (bucket.goal_amount <= 0) return 0;
|
||||
let amount = bucket.goal_amount;
|
||||
if (bucket.goal_type === 'percent') {
|
||||
amount = monthlyIncome * (bucket.goal_amount / 100);
|
||||
}
|
||||
if (bucket.goal_frequency === 'yearly') {
|
||||
amount = amount / 12;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function allBucketAllocations(
|
||||
buckets: Bucket[],
|
||||
monthlyIncome: number,
|
||||
): BucketAllocation[] {
|
||||
return buckets.map((b) => ({
|
||||
bucket: b,
|
||||
monthlyAmount: bucketMonthlyAllocation(b, monthlyIncome),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Debt ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* EMI formula: P * r * (1+r)^n / ((1+r)^n - 1)
|
||||
* r = monthly interest rate, n = term in months
|
||||
*/
|
||||
export function calculateEMI(principal: number, annualRate: number, termMonths: number): number {
|
||||
if (annualRate === 0) return principal / termMonths;
|
||||
const r = annualRate / 100 / 12;
|
||||
const emi = (principal * r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1);
|
||||
return emi;
|
||||
}
|
||||
|
||||
export function totalDebtPayments(debts: Debt[]): number {
|
||||
return debts.reduce((sum, d) => sum + d.monthly_payment, 0);
|
||||
}
|
||||
|
||||
// ─── Monthly Snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
export function buildMonthlySnapshot(
|
||||
sheet: BalanceSheet | null,
|
||||
incomes: Income[],
|
||||
buckets: Bucket[],
|
||||
debts: Debt[],
|
||||
): MonthlySnapshot {
|
||||
const monthlyIncome = totalMonthlyIncome(incomes);
|
||||
const buffer = sheet ? bufferAmount(sheet, monthlyIncome) : 0;
|
||||
const allocations = allBucketAllocations(buckets, monthlyIncome);
|
||||
const totalBucket = allocations.reduce((s, a) => s + a.monthlyAmount, 0);
|
||||
const debtPayments = totalDebtPayments(debts);
|
||||
const available = monthlyIncome - buffer - totalBucket - debtPayments;
|
||||
|
||||
return {
|
||||
balanceSheet: sheet,
|
||||
incomes,
|
||||
totalMonthlyIncome: monthlyIncome,
|
||||
bufferAmount: buffer,
|
||||
bucketAllocations: allocations,
|
||||
totalBucketAllocation: totalBucket,
|
||||
debtPayments,
|
||||
available,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Loan Calculator ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a monthly payment P, monthly rate r and term n, the max principal is:
|
||||
* principal = P * (1 - (1+r)^-n) / r
|
||||
*/
|
||||
export function maxLoanPrincipal(
|
||||
monthlyPayment: number,
|
||||
annualRate: number,
|
||||
termMonths: number,
|
||||
): number {
|
||||
if (annualRate === 0) return monthlyPayment * termMonths;
|
||||
const r = annualRate / 100 / 12;
|
||||
return (monthlyPayment * (1 - Math.pow(1 + r, -termMonths))) / r;
|
||||
}
|
||||
|
||||
export function analyzeLoan(
|
||||
availableMonthly: number,
|
||||
annualRate: number,
|
||||
termMonths: number,
|
||||
desiredPrincipal?: number,
|
||||
): LoanAnalysis {
|
||||
const r = annualRate / 100 / 12;
|
||||
const maxPrincipal = maxLoanPrincipal(availableMonthly, annualRate, termMonths);
|
||||
const principalForCalc = desiredPrincipal ?? maxPrincipal;
|
||||
const monthly = calculateEMI(principalForCalc, annualRate, termMonths);
|
||||
|
||||
return {
|
||||
maxAffordablePrincipal: maxPrincipal,
|
||||
monthlyPaymentForMax: availableMonthly,
|
||||
availableForLoan: availableMonthly,
|
||||
rateMonthly: r * 100,
|
||||
termMonths,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Debt repayment plan (avalanche method) ───────────────────────────────────
|
||||
|
||||
export function buildRepaymentPlan(
|
||||
debts: Debt[],
|
||||
extraMonthly: number,
|
||||
): DebtRepaymentPlan[] {
|
||||
if (debts.length === 0) return [];
|
||||
|
||||
// Sort by highest interest rate first (avalanche)
|
||||
const sorted = [...debts].sort((a, b) => b.interest_rate - a.interest_rate);
|
||||
let remaining = extraMonthly;
|
||||
|
||||
return sorted.map((debt, idx) => {
|
||||
const extra = idx === 0 ? remaining : 0; // put all extra on highest-rate debt
|
||||
const r = debt.interest_rate / 100 / 12;
|
||||
const payment = debt.monthly_payment + extra;
|
||||
let balance = debt.remaining_balance;
|
||||
let months = 0;
|
||||
let totalInterest = 0;
|
||||
|
||||
while (balance > 0 && months < 600) {
|
||||
const interest = balance * r;
|
||||
totalInterest += interest;
|
||||
const principal = Math.min(payment - interest, balance);
|
||||
balance -= principal;
|
||||
months++;
|
||||
if (payment <= interest) {
|
||||
months = 9999; // never paid off
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
debt,
|
||||
suggestedExtra: extra,
|
||||
monthsToPayoff: months,
|
||||
totalInterest,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Investment projection ────────────────────────────────────────────────────
|
||||
|
||||
export function projectInvestment(
|
||||
principal: number,
|
||||
annualReturnPercent: number,
|
||||
years: number,
|
||||
): number {
|
||||
const r = annualReturnPercent / 100;
|
||||
return principal * Math.pow(1 + r, years);
|
||||
}
|
||||
|
||||
export function buildInvestmentProjection(bucket: Bucket): InvestmentProjection {
|
||||
const annual =
|
||||
bucket.return_frequency === 'monthly'
|
||||
? bucket.return_percent * 12
|
||||
: bucket.return_percent;
|
||||
|
||||
const monthly = bucket.return_frequency === 'monthly'
|
||||
? bucket.current_balance * (bucket.return_percent / 100)
|
||||
: bucket.current_balance * (bucket.return_percent / 100 / 12);
|
||||
|
||||
return {
|
||||
bucket,
|
||||
monthlyReturn: monthly,
|
||||
projectedValue1Y: projectInvestment(bucket.current_balance, annual, 1),
|
||||
projectedValue5Y: projectInvestment(bucket.current_balance, annual, 5),
|
||||
projectedValue10Y: projectInvestment(bucket.current_balance, annual, 10),
|
||||
};
|
||||
}
|
||||
62
apps/web/src/lib/utils.ts
Normal file
62
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, decimals = 1): string {
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
try {
|
||||
return format(parseISO(dateString), 'MMM d, yyyy');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
export function monthName(month: number): string {
|
||||
return new Date(2000, month - 1, 1).toLocaleString('default', { month: 'long' });
|
||||
}
|
||||
|
||||
export function currentMonthYear(): { month: number; year: number } {
|
||||
const now = new Date();
|
||||
return { month: now.getMonth() + 1, year: now.getFullYear() };
|
||||
}
|
||||
|
||||
export function clsx(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export const BUCKET_COLORS = [
|
||||
'#6366f1', // indigo
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#f59e0b', // amber
|
||||
'#10b981', // emerald
|
||||
'#3b82f6', // blue
|
||||
'#ef4444', // red
|
||||
'#14b8a6', // teal
|
||||
'#f97316', // orange
|
||||
'#84cc16', // lime
|
||||
];
|
||||
|
||||
export function randomBucketColor(): string {
|
||||
return BUCKET_COLORS[Math.floor(Math.random() * BUCKET_COLORS.length)];
|
||||
}
|
||||
|
||||
export function monthsToReadable(months: number): string {
|
||||
if (months >= 9999) return 'Never (payment too low)';
|
||||
if (months >= 12) {
|
||||
const y = Math.floor(months / 12);
|
||||
const m = months % 12;
|
||||
return m > 0 ? `${y}y ${m}m` : `${y} year${y > 1 ? 's' : ''}`;
|
||||
}
|
||||
return `${months} month${months !== 1 ? 's' : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user