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
50 changes: 25 additions & 25 deletions client/src/pages/BudgetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,42 +41,42 @@ export default function BudgetPage() {
}

const needsTotal =
budget.housing.amount +
budget.utilities.amount +
budget.transportation.amount +
budget.groceries.amount +
budget.personalCare.amount +
budget.medical.amount
(budget.housing?.amount ?? 0) +
(budget.utilities?.amount ?? 0) +
(budget.transportation?.amount ?? 0) +
(budget.groceries?.amount ?? 0) +
(budget.personalCare?.amount ?? 0) +
(budget.medical?.amount ?? 0)

const wantsTotal =
budget.takeout.amount +
budget.shopping.amount +
budget.entertainment.amount
(budget.takeout?.amount ?? 0) +
(budget.shopping?.amount ?? 0) +
(budget.entertainment?.amount ?? 0)

const savingsDebtTotal =
budget.investments.amount +
budget.debts.amount +
budget.emergencyFund.amount
(budget.investments?.amount ?? 0) +
(budget.debts?.amount ?? 0) +
(budget.emergencyFund?.amount ?? 0)

const income = budget.income.amount
const income = budget.income?.amount ?? 0

const fmt = (v: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v)
const pct = (v: number) => `${income > 0 ? Math.round((v / income) * 100) : 0}%`

const categories = [
{ name: 'Housing', icon: <Home size={18} />, amount: budget.housing.amount, color: 'var(--color-chart-1)' },
{ name: 'Utilities', icon: <Zap size={18} />, amount: budget.utilities.amount, color: 'var(--color-chart-2)' },
{ name: 'Transportation', icon: <Car size={18} />, amount: budget.transportation.amount, color: 'var(--color-chart-3)' },
{ name: 'Groceries', icon: <ShoppingCart size={18} />, amount: budget.groceries.amount, color: 'var(--color-chart-4)' },
{ name: 'Medical', icon: <HeartPulse size={18} />, amount: budget.medical.amount, color: '#EC4899' },
{ name: 'Takeout', icon: <Utensils size={18} />, amount: budget.takeout.amount, color: 'var(--color-chart-5)' },
{ name: 'Shopping', icon: <ShoppingBag size={18} />, amount: budget.shopping.amount, color: 'var(--color-chart-6)' },
{ name: 'Personal Care', icon: <Scissors size={18} />, amount: budget.personalCare.amount, color: 'var(--color-chart-7)' },
{ name: 'Entertainment', icon: <Clapperboard size={18} />, amount: budget.entertainment.amount, color: '#8B5CF6' },
{ name: 'Emergency Fund', icon: <Shield size={18} />, amount: budget.emergencyFund.amount, color: '#06B6D4' },
{ name: 'Debts', icon: <CreditCard size={18} />, amount: budget.debts.amount, color: '#F97316' },
{ name: 'Investments', icon: <PiggyBank size={18} />, amount: budget.investments.amount, color: '#14B8A6' },
{ name: 'Housing', icon: <Home size={18} />, amount: budget.housing?.amount ?? 0, color: 'var(--color-chart-1)' },
{ name: 'Utilities', icon: <Zap size={18} />, amount: budget.utilities?.amount ?? 0, color: 'var(--color-chart-2)' },
{ name: 'Transportation', icon: <Car size={18} />, amount: budget.transportation?.amount ?? 0, color: 'var(--color-chart-3)' },
{ name: 'Groceries', icon: <ShoppingCart size={18} />, amount: budget.groceries?.amount ?? 0, color: 'var(--color-chart-4)' },
{ name: 'Medical', icon: <HeartPulse size={18} />, amount: budget.medical?.amount ?? 0, color: '#EC4899' },
{ name: 'Takeout', icon: <Utensils size={18} />, amount: budget.takeout?.amount ?? 0, color: 'var(--color-chart-5)' },
{ name: 'Shopping', icon: <ShoppingBag size={18} />, amount: budget.shopping?.amount ?? 0, color: 'var(--color-chart-6)' },
{ name: 'Personal Care', icon: <Scissors size={18} />, amount: budget.personalCare?.amount ?? 0, color: 'var(--color-chart-7)' },
{ name: 'Entertainment', icon: <Clapperboard size={18} />, amount: budget.entertainment?.amount ?? 0, color: '#8B5CF6' },
{ name: 'Emergency Fund', icon: <Shield size={18} />, amount: budget.emergencyFund?.amount ?? 0, color: '#06B6D4' },
{ name: 'Debts', icon: <CreditCard size={18} />, amount: budget.debts?.amount ?? 0, color: '#F97316' },
{ name: 'Investments', icon: <PiggyBank size={18} />, amount: budget.investments?.amount ?? 0, color: '#14B8A6' },
]

return (
Expand Down
60 changes: 30 additions & 30 deletions client/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,41 +84,41 @@ export default function DashboardPage() {
// Build chart data from real budget
const donutSlices: DonutSlice[] = budget
? [
{ name: 'Housing', value: budget.housing.amount, color: 'var(--color-chart-1)' },
{ name: 'Utilities', value: budget.utilities.amount, color: 'var(--color-chart-2)' },
{ name: 'Transportation', value: budget.transportation.amount, color: 'var(--color-chart-3)' },
{ name: 'Groceries', value: budget.groceries.amount, color: 'var(--color-chart-4)' },
{ name: 'Takeout', value: budget.takeout.amount, color: 'var(--color-chart-5)' },
{ name: 'Shopping', value: budget.shopping.amount, color: 'var(--color-chart-6)' },
{ name: 'Personal Care', value: budget.personalCare.amount, color: 'var(--color-chart-7)' },
{ name: 'Entertainment', value: budget.entertainment.amount, color: '#8B5CF6' },
{ name: 'Medical', value: budget.medical.amount, color: '#EC4899' },
{ name: 'Emergency Fund', value: budget.emergencyFund.amount, color: '#06B6D4' },
{ name: 'Debts', value: budget.debts.amount, color: '#F97316' },
{ name: 'Investments', value: budget.investments.amount, color: '#14B8A6' },
{ name: 'Housing', value: budget.housing?.amount ?? 0, color: 'var(--color-chart-1)' },
{ name: 'Utilities', value: budget.utilities?.amount ?? 0, color: 'var(--color-chart-2)' },
{ name: 'Transportation', value: budget.transportation?.amount ?? 0, color: 'var(--color-chart-3)' },
{ name: 'Groceries', value: budget.groceries?.amount ?? 0, color: 'var(--color-chart-4)' },
{ name: 'Takeout', value: budget.takeout?.amount ?? 0, color: 'var(--color-chart-5)' },
{ name: 'Shopping', value: budget.shopping?.amount ?? 0, color: 'var(--color-chart-6)' },
{ name: 'Personal Care', value: budget.personalCare?.amount ?? 0, color: 'var(--color-chart-7)' },
{ name: 'Entertainment', value: budget.entertainment?.amount ?? 0, color: '#8B5CF6' },
{ name: 'Medical', value: budget.medical?.amount ?? 0, color: '#EC4899' },
{ name: 'Emergency Fund', value: budget.emergencyFund?.amount ?? 0, color: '#06B6D4' },
{ name: 'Debts', value: budget.debts?.amount ?? 0, color: '#F97316' },
{ name: 'Investments', value: budget.investments?.amount ?? 0, color: '#14B8A6' },
].filter((s) => s.value > 0)
: []

const barData: BarDataPoint[] = budget
? [
{ label: 'Income', value: budget.income.amount },
{ label: 'Income', value: budget.income?.amount ?? 0 },
{
label: 'Expenses',
value:
budget.housing.amount +
budget.utilities.amount +
budget.transportation.amount +
budget.groceries.amount +
budget.takeout.amount +
budget.shopping.amount +
budget.personalCare.amount +
budget.entertainment.amount +
budget.medical.amount +
budget.debts.amount,
(budget.housing?.amount ?? 0) +
(budget.utilities?.amount ?? 0) +
(budget.transportation?.amount ?? 0) +
(budget.groceries?.amount ?? 0) +
(budget.takeout?.amount ?? 0) +
(budget.shopping?.amount ?? 0) +
(budget.personalCare?.amount ?? 0) +
(budget.entertainment?.amount ?? 0) +
(budget.medical?.amount ?? 0) +
(budget.debts?.amount ?? 0),
},
{
label: 'Savings',
value: budget.investments.amount + budget.emergencyFund.amount,
value: (budget.investments?.amount ?? 0) + (budget.emergencyFund?.amount ?? 0),
},
]
: []
Expand All @@ -133,7 +133,7 @@ export default function DashboardPage() {
<div className="dashboard-page__charts">
<ChartCard
title="Income vs Expenses"
subtitle={budget ? `Monthly income: $${budget.income.amount.toLocaleString()}` : ''}
subtitle={budget ? `Monthly income: $${(budget.income?.amount ?? 0).toLocaleString()}` : ''}
>
{budget && barData.some((d) => d.value > 0) ? (
<BarChart data={barData} color="var(--color-chart-1)" />
Expand All @@ -151,20 +151,20 @@ export default function DashboardPage() {
</ChartCard>

<ChartCard title="Savings & Investments" subtitle="Monthly allocation">
{budget && budget.investments.amount > 0 ? (
{budget && (budget.investments?.amount ?? 0) > 0 ? (
<div className="dashboard-page__invest-stat">
<span className="dashboard-page__invest-amount">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(budget.investments.amount)}
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(budget.investments?.amount ?? 0)}
</span>
<span className="dashboard-page__invest-sub">
{budget.income.amount > 0
? `${((budget.investments.amount / budget.income.amount) * 100).toFixed(1)}% of monthly income`
{(budget.income?.amount ?? 0) > 0
? `${(((budget.investments?.amount ?? 0) / (budget.income?.amount ?? 1)) * 100).toFixed(1)}% of monthly income`
: 'per month'}
</span>
<div className="dashboard-page__invest-bar-bg">
<div
className="dashboard-page__invest-bar-fill"
style={{ width: `${Math.min((budget.investments.amount / budget.income.amount) * 100, 100)}%` }}
style={{ width: `${Math.min(((budget.investments?.amount ?? 0) / (budget.income?.amount ?? 1)) * 100, 100)}%` }}
/>
</div>
</div>
Expand Down
14 changes: 7 additions & 7 deletions client/src/pages/LinkBankPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,11 @@ export default function LinkBankPage() {
setLoading(true)
try {
// Fire debt/investing agents only if allocations are nonzero
if (budget && budget.debts.amount > 0) {
api.post('/api/agent/debt', { debtAllocation: budget.debts.amount }).catch(() => {})
if (budget && (budget.debts?.amount ?? 0) > 0) {
api.post('/api/agent/debt', { debtAllocation: budget.debts?.amount ?? 0 }).catch(() => {})
}
if (budget && budget.investments.amount > 0) {
api.post('/api/agent/investing', { investingAllocation: budget.investments.amount }).catch(() => {})
if (budget && (budget.investments?.amount ?? 0) > 0) {
api.post('/api/agent/investing', { investingAllocation: budget.investments?.amount ?? 0 }).catch(() => {})
}
setStep(6)
} finally {
Expand Down Expand Up @@ -665,7 +665,7 @@ export default function LinkBankPage() {
<span>Change</span>
</div>
{BUDGET_CATEGORIES.map(({ key, label, icon }) => {
const current = (budget[key as BudgetKey] as { amount: number }).amount
const current = ((budget[key as BudgetKey] as { amount: number } | undefined)?.amount ?? 0)
const proposedRaw = getProposedAmount(result, key)
const proposed = proposedRaw ?? current
const { label: diffLabel, positive } = diff(current, proposed)
Expand Down Expand Up @@ -742,7 +742,7 @@ export default function LinkBankPage() {
function renderBudgetSummary(b: Budget) {
const sections = ['Income', 'Needs', 'Wants', 'Savings'] as const
const totalExpenses = BUDGET_CATEGORIES.filter((c) => c.key !== 'income').reduce(
(sum, { key }) => sum + (b[key as BudgetKey] as { amount: number }).amount,
(sum, { key }) => sum + ((b[key as BudgetKey] as { amount: number } | undefined)?.amount ?? 0),
0,
)
const remaining = b.income.amount - totalExpenses
Expand All @@ -755,7 +755,7 @@ export default function LinkBankPage() {
<div key={section} className="onboard__budget-section">
<p className="onboard__budget-section-header">{section}</p>
{cats.map(({ key, label, icon }) => {
const amount = (b[key as BudgetKey] as { amount: number }).amount
const amount = ((b[key as BudgetKey] as { amount: number } | undefined)?.amount ?? 0)
return (
<div key={key} className="onboard__budget-row">
<span>{icon} {label}</span>
Expand Down
18 changes: 0 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"@getbrevo/brevo": "^5.0.3",
"@strands-agents/sdk": "^0.7.0",
"argon2": "^0.44.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.3.1",
"fastify": "^5.7.4",
"fastify-graceful-shutdown": "^5.0.0",
Expand All @@ -42,7 +41,6 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/aws-lambda": "^8.10.160",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.0",
"@types/uuid": "^10.0.0",
Expand Down
2 changes: 1 addition & 1 deletion server/src/tests/vitest.global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { execSync } from 'node:child_process';
import { setupTables } from '../scripts/setup-tables.js';

const CONTAINER_NAME = 'dynamodb-vitest';
const HOST_PORT = '8000';
const HOST_PORT = '8001';
const ENDPOINT = `http://localhost:${HOST_PORT}`;

/** Maximum time (ms) to wait for DynamoDB Local to accept connections. */
Expand Down
37 changes: 27 additions & 10 deletions terraform/lambda.tf
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,30 @@ resource "aws_iam_role_policy" "lambda_dynamodb" {
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
]
Resource = [
aws_dynamodb_table.users.arn,
"${aws_dynamodb_table.users.arn}/index/email-index",
aws_dynamodb_table.auth_tokens.arn,
aws_dynamodb_table.budgets.arn,
aws_dynamodb_table.proposals.arn,
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Users",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Users/index/email-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Users/index/EmailVerificationTokenIndex",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Users/index/passwordResetToken-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/auth_tokens",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/auth_tokens/index/userId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Budgets",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Proposals",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/PlaidItems",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/PlaidItems/index/itemId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Accounts",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Accounts/index/itemId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Accounts/index/plaidAccountId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Transactions",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Transactions/index/plaidTransactionId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Transactions/index/accountId-date-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/InvestmentTransactions",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/InvestmentTransactions/index/plaidInvestmentTransactionId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Holdings",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Holdings/index/plaidAccountId-index",
"arn:aws:dynamodb:${var.aws_region}:${data.aws_caller_identity.current.account_id}:table/Liabilities",
]
}]
})
Expand Down Expand Up @@ -93,16 +110,16 @@ resource "aws_lambda_function" "api" {
runtime = "nodejs20.x"
handler = "lambda.handler"
timeout = 29
memory_size = 512
filename = data.archive_file.lambda_placeholder.output_path
source_code_hash = data.archive_file.lambda_placeholder.output_base64sha256

environment {
variables = {
NODE_ENV = "production"
JWT_SECRET = data.aws_ssm_parameter.jwt_secret.value
FRONTEND_URL = data.aws_ssm_parameter.frontend_url.value
ENCRYPTION_KEY = data.aws_ssm_parameter.encryption_key.value
AGENT_SERVICE_URL = var.domain_name != "" ? "https://${var.domain_name}" : "http://${aws_lb.agents.dns_name}"
NODE_ENV = "production"
JWT_SECRET = data.aws_ssm_parameter.jwt_secret.value
FRONTEND_URL = data.aws_ssm_parameter.frontend_url.value
ENCRYPTION_KEY = data.aws_ssm_parameter.encryption_key.value
}
}

Expand Down
2 changes: 2 additions & 0 deletions terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ terraform {
}
}

data "aws_caller_identity" "current" {}

provider "aws" {
region = var.aws_region

Expand Down
Loading