Init commit!

This commit is contained in:
Jennie Robinson Faber 2025-08-22 18:36:16 +01:00
commit 086d682592
34 changed files with 19249 additions and 0 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# MongoDB Database Connection
MONGO_URI=mongodb://localhost:27017/faber-finances
# Wise API Personal Access Tokens
# Get your tokens from: https://wise.com/help/articles/2958231/personal-access-tokens
# Documentation: https://docs.wise.com/api-docs/guides/personal-tokens
WISE_API_KEY_JENNIE=your_jennie_wise_token_here
WISE_API_KEY_HENRY=your_henry_wise_token_here
# Note: These tokens should have permission to read account balances
# Copy this file to .env and fill in your actual tokens

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
.aider*

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

26
app/app.vue Normal file
View file

@ -0,0 +1,26 @@
<template>
<div>
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<NuxtLink to="/" class="text-xl font-bold text-gray-900 font-mono">
Faber Finances
</NuxtLink>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<NuxtPage />
</div>
</template>
<script setup>
// Global SEO
useSeoMeta({
titleTemplate: "%s - Faber Finances",
description: "Personal finance and cash flow management system",
});
</script>

View file

@ -0,0 +1,433 @@
<template>
<div v-if="isOpen" class="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border-2 border-black w-full max-w-2xl shadow-lg bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-medium text-black">Update Available Balances</h3>
<button
@click="closeModal"
class="text-black hover:text-red-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="updateBalances" class="space-y-6">
<!-- Manual Entry Balances -->
<div class="border-2 border-black p-4">
<div class="flex justify-between items-center mb-4">
<div>
<h4 class="text-md font-semibold text-black">Manual Entry</h4>
<div v-if="lastManualUpdate" class="text-xs text-black mt-1">
Last updated: {{ formatTimestamp(lastManualUpdate) }}
</div>
</div>
</div>
<!-- RBC -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-black mb-1">
RBC Available Cash (CAD $)
</label>
<input
v-model.number="balances.rbc_cad"
type="number"
step="0.01"
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="0.00" />
<p class="text-xs text-black mt-1">Including overdraft limit</p>
</div>
<!-- TD -->
<div>
<label class="block text-sm font-medium text-black mb-1">
TD Available Cash (CAD $)
</label>
<input
v-model.number="balances.td_cad"
type="number"
step="0.01"
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="0.00" />
<p class="text-xs text-black mt-1">Including overdraft limit</p>
</div>
</div>
<!-- Millennium -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-black mb-1">
Millennium Balance ()
</label>
<input
v-model.number="balances.millennium_eur"
type="number"
step="0.01"
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="0.00" />
</div>
</div>
</div>
<!-- Wise API Balances -->
<div class="border rounded-lg p-4">
<div class="flex justify-between items-center mb-4">
<div>
<h4 class="text-md font-semibold text-gray-800">Wise Balances</h4>
<div v-if="lastWiseFetch" class="text-xs text-gray-500 mt-1">
Last fetched: {{ formatTimestamp(lastWiseFetch) }}
</div>
</div>
<button
type="button"
@click="fetchWiseBalances"
:disabled="loading"
class="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white text-sm font-medium py-1 px-3 border-2 border-green-700">
{{ loading ? 'Fetching...' : 'Fetch from API' }}
</button>
</div>
<!-- Jennie Wise -->
<div class="mb-4">
<h5 class="text-sm font-medium text-black mb-2">Jennie Wise Balances</h5>
<div v-if="wiseBalances.jennie && wiseBalances.jennie.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div v-for="balance in wiseBalances.jennie" :key="`jennie-${balance.currency}`" class="bg-white border-2 border-black p-3">
<div class="text-xs text-black">{{ balance.currency }}</div>
<div class="text-sm font-medium">{{ formatCurrency(balance.value.value, balance.currency) }}</div>
</div>
</div>
<div v-else class="text-sm text-black italic">
{{ loading ? 'Loading...' : 'No balances fetched yet' }}
</div>
</div>
<!-- Henry Wise -->
<div>
<h5 class="text-sm font-medium text-black mb-2">Henry Wise Balances</h5>
<div v-if="wiseBalances.henry && wiseBalances.henry.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div v-for="balance in wiseBalances.henry" :key="`henry-${balance.currency}`" class="bg-white border-2 border-black p-3">
<div class="text-xs text-black">{{ balance.currency }}</div>
<div class="text-sm font-medium">{{ formatCurrency(balance.value.value, balance.currency) }}</div>
</div>
</div>
<div v-else class="text-sm text-black italic">
{{ loading ? 'Loading...' : 'No balances fetched yet' }}
</div>
</div>
</div>
<!-- Total in CAD -->
<div class="border-t pt-4">
<div class="bg-white border-2 border-black p-4">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-black">Total Available Balance (CAD $)</span>
<span class="text-lg font-bold text-blue-600">{{ formatCurrency(totalBalanceCAD, 'CAD') }}</span>
</div>
<p class="text-xs text-black mt-1">All balances converted to CAD using current exchange rates</p>
</div>
</div>
<!-- Error Display -->
<div v-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{{ error }}
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-black bg-white border-2 border-black hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-black">
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border-2 border-blue-800 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
Update Balances
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
currentBalance: {
type: Number,
default: 0
}
});
const emit = defineEmits(['close', 'update']);
// State
const loading = ref(false);
const error = ref('');
// Balance storage
const balances = ref({
rbc_cad: 0,
td_cad: 0,
millennium_eur: 0,
});
const wiseBalances = ref({
jennie: [],
henry: []
});
// Exchange rates - updated from Wise API for accurate conversions
const exchangeRates = ref({
CAD: 1.0,
EUR: 1.45, // Will be updated from Wise API
USD: 1.35, // Fallback rate
GBP: 1.65 // Fallback rate
});
// Fetch real-time exchange rates for balance conversions
const fetchBalanceExchangeRates = async () => {
try {
// Fetch EUR to CAD rate (most common conversion)
const eurResponse = await fetch('/api/wise/exchange-rates?source=EUR&target=CAD');
if (eurResponse.ok) {
const eurData = await eurResponse.json();
exchangeRates.value.EUR = eurData.rate;
}
} catch (error) {
console.error('Failed to update exchange rates for balances:', error);
// Keep using fallback rates
}
};
// Timestamps for last updates
const lastWiseFetch = ref(null);
const lastManualUpdate = ref(null);
// Watch for modal open to load saved data
watch(() => props.isOpen, (newVal) => {
if (newVal) {
loadSavedBalances();
fetchBalanceExchangeRates(); // Update exchange rates when modal opens
}
});
// Load saved balances from MongoDB
const loadSavedBalances = async () => {
try {
const response = await fetch('/api/balances');
if (response.ok) {
const data = await response.json();
if (data.accountBalances) {
// Pre-fill with previous values
balances.value = {
rbc_cad: data.accountBalances.manual?.rbc_cad || 0,
td_cad: data.accountBalances.manual?.td_cad || 0,
millennium_eur: data.accountBalances.manual?.millennium_eur || 0,
};
wiseBalances.value = {
jennie: data.accountBalances.wise?.jennie || [],
henry: data.accountBalances.wise?.henry || []
};
// Load timestamps
lastWiseFetch.value = data.accountBalances.lastWiseFetch ? new Date(data.accountBalances.lastWiseFetch) : null;
lastManualUpdate.value = data.accountBalances.lastManualUpdate ? new Date(data.accountBalances.lastManualUpdate) : null;
}
}
} catch (error) {
console.error('Failed to load balances from MongoDB:', error);
// Fallback to localStorage
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('account_balances');
if (saved) {
const parsed = JSON.parse(saved);
balances.value = {
rbc_cad: parsed.manual?.rbc_cad || 0,
td_cad: parsed.manual?.td_cad || 0,
millennium_eur: parsed.manual?.millennium_eur || 0,
};
wiseBalances.value = {
jennie: parsed.wise?.jennie || [],
henry: parsed.wise?.henry || []
};
lastWiseFetch.value = parsed.lastWiseFetch ? new Date(parsed.lastWiseFetch) : null;
lastManualUpdate.value = parsed.lastManualUpdate ? new Date(parsed.lastManualUpdate) : null;
}
}
}
};
// Save balances to MongoDB
const saveBalances = async () => {
try {
// Update manual update timestamp
lastManualUpdate.value = new Date();
const requestBody = {
currentBalance: totalBalanceCAD.value,
accountBalances: {
manual: balances.value,
wise: wiseBalances.value,
lastWiseFetch: lastWiseFetch.value?.toISOString(),
lastManualUpdate: lastManualUpdate.value.toISOString()
}
};
const response = await fetch('/api/balances', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error('Failed to save balances');
}
console.log("✅ Balances saved to MongoDB");
} catch (error) {
console.error('Failed to save balances to MongoDB:', error);
// Fallback to localStorage
if (typeof window !== 'undefined') {
const data = {
manual: balances.value,
wise: wiseBalances.value,
lastWiseFetch: lastWiseFetch.value?.toISOString(),
lastManualUpdate: lastManualUpdate.value.toISOString()
};
localStorage.setItem('account_balances', JSON.stringify(data));
}
}
};
// Fetch Wise balances via API
const fetchWiseBalances = async () => {
loading.value = true;
error.value = '';
try {
// Fetch balances for both profiles
const [jennieResponse, henryResponse] = await Promise.allSettled([
fetch('/api/wise/jennie/balances'),
fetch('/api/wise/henry/balances')
]);
const newBalances = { jennie: [], henry: [] };
// Handle Jennie's balances
if (jennieResponse.status === 'fulfilled' && jennieResponse.value.ok) {
newBalances.jennie = await jennieResponse.value.json();
} else {
console.warn('Failed to fetch Jennie Wise balances:', jennieResponse.reason || jennieResponse.value.statusText);
}
// Handle Henry's balances
if (henryResponse.status === 'fulfilled' && henryResponse.value.ok) {
newBalances.henry = await henryResponse.value.json();
} else {
console.warn('Failed to fetch Henry Wise balances:', henryResponse.reason || henryResponse.value.statusText);
}
wiseBalances.value = newBalances;
// Update Wise fetch timestamp if successful
if (newBalances.jennie.length > 0 || newBalances.henry.length > 0) {
lastWiseFetch.value = new Date();
}
// Show error only if both failed
if (!newBalances.jennie.length && !newBalances.henry.length) {
error.value = 'Failed to fetch Wise balances. Please check your API tokens or try again later.';
}
} catch (err) {
error.value = 'Failed to fetch Wise balances. Please try again or enter manually.';
console.error('Wise API error:', err);
} finally {
loading.value = false;
}
};
// Calculate total balance in CAD
const totalBalanceCAD = computed(() => {
let total = 0;
// Manual balances - Personal accounts
total += balances.value.rbc_cad || 0;
total += balances.value.td_cad || 0;
total += (balances.value.millennium_eur || 0) * exchangeRates.value.EUR;
// Wise balances
[...(wiseBalances.value.jennie || []), ...(wiseBalances.value.henry || [])].forEach(balance => {
const rate = exchangeRates.value[balance.currency] || 1;
total += balance.value.value * rate;
});
return total;
});
// Format currency
const formatCurrency = (amount, currency = 'CAD') => {
const symbols = {
CAD: '$',
USD: '$',
EUR: '€',
GBP: '£'
};
return new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount || 0);
};
// Format timestamp
const formatTimestamp = (date) => {
if (!date) return '';
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Intl.DateTimeFormat('en-CA', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
// Close modal
const closeModal = () => {
error.value = '';
emit('close');
};
// Update balances
const updateBalances = async () => {
await saveBalances();
emit('update', totalBalanceCAD.value);
closeModal();
};
</script>

View file

@ -0,0 +1,440 @@
<template>
<div class="space-y-4">
<!-- Chart Header -->
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-semibold text-gray-900">
Cash Flow Projection
</h3>
<p class="text-sm text-gray-600">
{{ selectedWeeks }}-week runway analysis
</p>
</div>
<div class="flex items-center space-x-6">
<!-- Week Controls -->
<div class="flex space-x-2">
<UButton
@click="selectedWeeks = 8"
:variant="selectedWeeks === 8 ? 'solid' : 'outline'"
size="sm">
8 Weeks
</UButton>
<UButton
@click="selectedWeeks = 13"
:variant="selectedWeeks === 13 ? 'solid' : 'outline'"
size="sm">
13 Weeks
</UButton>
<UButton
@click="selectedWeeks = 26"
:variant="selectedWeeks === 26 ? 'solid' : 'outline'"
size="sm">
26 Weeks
</UButton>
</div>
<!-- Balance Info -->
<div class="flex space-x-4">
<div class="text-center">
<p class="text-sm text-gray-600">Starting Balance</p>
<p class="font-semibold text-blue-600">
{{ formatCurrency(startingBalance) }}
</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Projected Balance</p>
<p class="font-semibold" :class="projectedBalanceColor">
{{ formatCurrency(endingBalance) }}
</p>
</div>
</div>
</div>
</div>
<!-- Chart Container -->
<div class="relative h-64 bg-gray-50 rounded-lg p-4">
<!-- Y-axis labels -->
<div
class="absolute left-4 top-4 bottom-8 w-20 flex flex-col justify-between text-xs text-gray-500">
<span>{{ formatCurrency(maxValue) }}</span>
<span>{{ formatCurrency(maxValue * 0.75) }}</span>
<span>{{ formatCurrency(maxValue * 0.5) }}</span>
<span>{{ formatCurrency(maxValue * 0.25) }}</span>
<span>$0</span>
</div>
<!-- Chart area -->
<div class="ml-24 mr-16 h-full relative">
<!-- Zero line -->
<div
class="absolute w-full border-t-2 border-red-300 border-dashed"
:style="`bottom: ${getYPosition(0)}%`">
<span
class="absolute -right-12 -top-2 text-xs text-red-500 bg-white px-1"
>$0</span
>
</div>
<!-- Critical balance line -->
<div
v-if="criticalBalance > 0 && criticalBalance <= maxValue"
class="absolute w-full border-t border-orange-400 border-dashed"
:style="`bottom: ${getYPosition(criticalBalance)}%`">
<span
class="absolute -right-12 -top-2 text-xs text-orange-600 bg-white px-1"
>Critical</span
>
</div>
<!-- Cash flow line -->
<svg
class="w-full h-full"
viewBox="0 0 400 240"
preserveAspectRatio="xMidYMid meet">
<!-- Grid lines -->
<defs>
<pattern
id="grid"
width="30.77"
height="40"
patternUnits="userSpaceOnUse">
<path
d="M 30.77 0 L 0 0 0 40"
fill="none"
stroke="#e5e7eb"
stroke-width="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
<!-- Area fill -->
<defs>
<linearGradient
id="cashFlowGradient"
x1="0%"
y1="0%"
x2="0%"
y2="100%">
<stop
offset="0%"
style="stop-color: #3b82f6; stop-opacity: 0.3" />
<stop
offset="100%"
style="stop-color: #3b82f6; stop-opacity: 0.05" />
</linearGradient>
</defs>
<path :d="cashFlowPath" fill="url(#cashFlowGradient)" stroke="none" />
<!-- Cash flow line -->
<path
:d="cashFlowPath"
fill="none"
stroke="#3b82f6"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round" />
<!-- Data points -->
<circle
v-for="(point, index) in chartPoints"
:key="index"
:cx="point.x"
:cy="point.y"
r="4"
:fill="getPointColor(point.balance)"
stroke="white"
stroke-width="2"
class="cursor-pointer hover:r-6 transition-all"
@mouseenter="showTooltip(index, $event)"
@mouseleave="hideTooltip" />
<!-- X-axis labels inside SVG -->
<text
v-for="(point, index) in xAxisLabelPoints"
:key="`label-${index}`"
:x="point.x"
:y="220"
text-anchor="middle"
fill="#6b7280"
font-size="10"
font-family="system-ui, -apple-system, sans-serif">
{{ point.label }}
</text>
</svg>
<!-- Tooltip -->
<div
v-if="tooltip.show"
class="absolute z-10 bg-gray-900 text-white text-xs rounded py-2 px-3 pointer-events-none"
:style="tooltipStyle">
<div class="font-semibold">Week {{ tooltip.week }}</div>
<div>Balance: {{ formatCurrency(tooltip.balance) }}</div>
<div>Cash Flow: {{ formatCurrency(tooltip.netFlow) }}</div>
<div>{{ formatDate(tooltip.date) }}</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="flex items-center justify-center space-x-6 text-sm">
<div class="flex items-center space-x-2">
<div class="w-4 h-0.5 bg-blue-500"></div>
<span class="text-gray-600">Cash Flow</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-0.5 border-t-2 border-red-300 border-dashed"></div>
<span class="text-gray-600">Break Even</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-0.5 border-t border-orange-400 border-dashed"></div>
<span class="text-gray-600">Critical Level</span>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div class="text-center p-3 bg-blue-50 rounded-lg">
<p class="text-sm font-medium text-blue-800">Lowest Point</p>
<p class="text-lg font-bold text-blue-600">
{{ formatCurrency(lowestBalance) }}
</p>
<p class="text-xs text-blue-600">Week {{ lowestWeek }}</p>
</div>
<div class="text-center p-3 bg-green-50 rounded-lg">
<p class="text-sm font-medium text-green-800">Total Inflow</p>
<p class="text-lg font-bold text-green-600">
{{ formatCurrency(totalInflow) }}
</p>
</div>
<div class="text-center p-3 bg-red-50 rounded-lg">
<p class="text-sm font-medium text-red-800">Total Outflow</p>
<p class="text-lg font-bold text-red-600">
{{ formatCurrency(totalOutflow) }}
</p>
</div>
<div class="text-center p-3 bg-purple-50 rounded-lg">
<p class="text-sm font-medium text-purple-800">Net Change</p>
<p class="text-lg font-bold text-purple-600">
{{ formatCurrency(netChange) }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CashFlowProjection } from "~/types/cashflow";
interface Props {
data: CashFlowProjection[];
criticalBalance?: number;
}
const props = withDefaults(defineProps<Props>(), {
criticalBalance: 50000,
});
const emit = defineEmits<{
"weeks-changed": [weeks: number];
}>();
// Week selection
const selectedWeeks = ref(13);
// Watch for changes and emit to parent
watch(selectedWeeks, (newWeeks) => {
emit("weeks-changed", newWeeks);
});
// Reactive state for tooltip
const tooltip = ref({
show: false,
week: 0,
balance: 0,
netFlow: 0,
date: new Date(),
x: 0,
y: 0,
});
// Computed properties
const startingBalance = computed(() =>
props.data.length > 0 ? props.data[0].balance : 0
);
const endingBalance = computed(() =>
props.data.length > 0 ? props.data[props.data.length - 1].projectedBalance : 0
);
const maxValue = computed(() => {
const balances = props.data.map((d) =>
Math.max(d.balance, d.projectedBalance)
);
const max = Math.max(...balances, props.criticalBalance);
return Math.ceil((max * 1.1) / 10000) * 10000;
});
const minValue = computed(() => {
const balances = props.data.map((d) =>
Math.min(d.balance, d.projectedBalance)
);
return Math.min(...balances, 0);
});
const chartPoints = computed(() => {
if (props.data.length === 0) return [];
return props.data.map((point, index) => {
const x =
props.data.length === 1 ? 200 : (index / (props.data.length - 1)) * 400;
const y = 200 - (getYPosition(point.projectedBalance) / 100) * 200;
return {
x,
y,
balance: point.projectedBalance,
date: point.date,
netFlow: point.netFlow,
};
});
});
const cashFlowPath = computed(() => {
if (chartPoints.value.length === 0) return "";
let path = `M ${chartPoints.value[0].x} ${chartPoints.value[0].y}`;
for (let i = 1; i < chartPoints.value.length; i++) {
path += ` L ${chartPoints.value[i].x} ${chartPoints.value[i].y}`;
}
const lastPoint = chartPoints.value[chartPoints.value.length - 1];
const firstPoint = chartPoints.value[0];
path += ` L ${lastPoint.x} 200 L ${firstPoint.x} 200 Z`;
return path;
});
const xAxisLabelPoints = computed(() => {
if (props.data.length === 0) return [];
const maxLabels = 6;
const step = Math.max(1, Math.floor(props.data.length / maxLabels));
const labels = [];
for (let i = 0; i < props.data.length; i += step) {
const point = props.data[i];
const x =
props.data.length === 1 ? 200 : (i / (props.data.length - 1)) * 400;
labels.push({
label: point.date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
x: x,
});
}
if (props.data.length > 1) {
const lastIndex = props.data.length - 1;
const lastIncluded = labels[labels.length - 1];
const lastX = (lastIndex / (props.data.length - 1)) * 400;
if (Math.abs(lastIncluded.x - lastX) > 20) {
labels.push({
label: props.data[lastIndex].date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
x: 400,
});
}
}
return labels;
});
const projectedBalanceColor = computed(() => {
if (endingBalance.value < 0) return "text-red-600";
if (endingBalance.value < props.criticalBalance) return "text-orange-600";
if (endingBalance.value < startingBalance.value) return "text-yellow-600";
return "text-green-600";
});
const lowestBalance = computed(() =>
Math.min(...props.data.map((d) => d.projectedBalance))
);
const lowestWeek = computed(() => {
const lowest = lowestBalance.value;
const index = props.data.findIndex((d) => d.projectedBalance === lowest);
return index + 1;
});
const totalInflow = computed(() =>
props.data.reduce((sum, d) => sum + d.inflow, 0)
);
const totalOutflow = computed(() =>
props.data.reduce((sum, d) => sum + Math.abs(d.outflow), 0)
);
const netChange = computed(() => endingBalance.value - startingBalance.value);
const tooltipStyle = computed(() => ({
left: `${tooltip.value.x}px`,
top: `${tooltip.value.y - 60}px`,
transform: "translateX(-50%)",
}));
// Helper functions
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
};
const getYPosition = (value: number) => {
const range = maxValue.value - minValue.value;
return ((value - minValue.value) / range) * 100;
};
const getPointColor = (balance: number) => {
if (balance < 0) return "#dc2626";
if (balance < props.criticalBalance) return "#ea580c";
if (balance < props.criticalBalance * 1.5) return "#d97706";
return "#059669";
};
const showTooltip = (index: number, event: MouseEvent) => {
const point = props.data[index];
if (point) {
tooltip.value = {
show: true,
week: index + 1,
balance: point.projectedBalance,
netFlow: point.netFlow,
date: point.date,
x: event.offsetX,
y: event.offsetY,
};
}
};
const hideTooltip = () => {
tooltip.value.show = false;
};
</script>

View file

@ -0,0 +1,431 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-semibold">Recurring Transactions</h3>
</div>
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded flex items-center">
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Recurring Transaction
</button>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Monthly Recurring Income</p>
<p class="text-2xl font-bold text-green-600">
{{ formatCurrency(monthlyRecurringIncome) }}
</p>
<p class="text-xs mt-1">{{ recurringIncomeCount }} income sources</p>
<div v-if="monthlyRecurringIncome === 0">
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white text-xs py-1 px-2 border-2 border-blue-700 mt-2">
Add Income
</button>
</div>
</div>
</div>
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Monthly Recurring Expenses</p>
<p class="text-2xl font-bold text-red-600">
{{ formatCurrency(monthlyRecurringExpenses) }}
</p>
<p class="text-xs mt-1">
{{ recurringExpenseCount }} expense sources
</p>
</div>
</div>
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Net Monthly Cash Flow</p>
<p class="text-2xl font-bold" :class="netCashFlowColor">
{{ formatCurrency(netMonthlyCashFlow) }}
</p>
</div>
</div>
</div>
<!-- Filters and Search -->
<div
class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex space-x-2">
<button
@click="activeFilter = 'all'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'all'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
All ({{ allRecurringTransactions.length }})
</button>
<button
@click="activeFilter = 'income'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'income'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
Income ({{ recurringIncomeTransactions.length }})
</button>
<button
@click="activeFilter = 'expenses'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'expenses'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
Expenses ({{ recurringExpenseTransactions.length }})
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search recurring transactions..."
class="w-full sm:w-64 px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- Recurring Transactions Table -->
<div class="bg-white border-2 border-black shadow overflow-hidden">
<div
class="flex justify-between items-center p-4 border-b-2 border-black">
<h4 class="font-semibold">Recurring Transactions</h4>
<span class="text-sm text-black">
{{ filteredTransactions.length }} transactions
</span>
</div>
<div v-if="filteredTransactions.length === 0" class="text-center py-8">
<svg
class="h-12 w-12 mx-auto mb-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<h3 class="text-lg font-medium mb-2">No Recurring Transactions</h3>
<p class="mb-4 text-gray-600">
Add recurring transactions to automate your cash flow projections
</p>
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 border-2 border-blue-700">
Add Your First Recurring Transaction
</button>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead class="bg-black text-white">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Description
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Category
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Frequency
</th>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
Amount
</th>
<th
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Active Period
</th>
<th
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-black">
<tr
v-for="transaction in filteredTransactions"
:key="transaction.id || transaction._id"
class="hover:bg-blue-50 transition-colors cursor-pointer border-b border-black"
@click="editTransaction(transaction)">
<td class="px-6 py-4">
<div class="font-medium text-black">
{{ transaction.description }}
</div>
<div
v-if="transaction.notes"
class="text-xs text-black mt-1">
{{ transaction.notes }}
</div>
</td>
<td class="px-6 py-4">
<span
:class="getCategoryBadgeClass(transaction.category)"
class="inline-flex px-2 py-1 text-xs font-semibold border-2 border-black">
{{ transaction.category }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<svg
class="h-4 w-4 mr-1 text-black"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="capitalize text-sm">
{{
transaction.frequency === "custom"
? `Every ${transaction.customMonths} months`
: transaction.frequency || "monthly"
}}
</span>
</div>
</td>
<td class="px-6 py-4 text-right">
<div
class="font-medium"
:class="
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
">
{{ formatCurrency(transaction.amount) }}
</div>
<!-- Show business split info if applicable -->
<div v-if="transaction.businessPercentage > 0" class="text-xs text-black mt-1 space-y-0.5">
<div>Personal: {{ formatCurrency(transaction.amount) }}</div>
<div class="text-blue-600">Business: {{ formatCurrency(transaction.businessAmount || 0) }} ({{ transaction.businessPercentage }}%)</div>
<div class="font-medium">Total: {{ formatCurrency(transaction.totalAmount || transaction.amount) }}</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span
:class="
transaction.isConfirmed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold border-2 border-black">
{{ transaction.isConfirmed ? "CONFIRMED" : "PENDING" }}
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-black">
{{ formatDate(transaction.date) }}
</div>
<div v-if="transaction.endDate" class="text-xs text-black">
Until {{ formatDate(transaction.endDate) }}
</div>
</td>
<td class="px-6 py-4 text-center">
<button
@click.stop="editTransaction(transaction)"
class="bg-white hover:bg-blue-50 text-black font-medium py-1 px-3 border-2 border-black text-sm">
Edit
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
transactions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"add-transaction",
"edit-transaction",
"delete-transaction",
]);
// State
const activeFilter = ref("all");
const searchQuery = ref("");
// Computed properties for filtering
const allRecurringTransactions = computed(() => {
return props.transactions
.filter((t) => t.type === "recurring")
.sort((a, b) => {
if (a.amount > 0 && b.amount <= 0) return -1;
if (a.amount <= 0 && b.amount > 0) return 1;
return Math.abs(b.amount) - Math.abs(a.amount);
});
});
const recurringIncomeTransactions = computed(() => {
return allRecurringTransactions.value.filter((t) => t.amount > 0);
});
const recurringExpenseTransactions = computed(() => {
return allRecurringTransactions.value.filter((t) => t.amount < 0);
});
const activeIncomeTransactions = computed(() => {
const now = new Date();
return recurringIncomeTransactions.value.filter((t) => {
const startDate = new Date(t.date);
const endDate = t.endDate ? new Date(t.endDate) : new Date("2099-12-31");
const isActive = now >= startDate && now <= endDate;
const isCommittedOrActual =
t.status === "actual" || t.status === "committed" || t.isConfirmed;
return isActive && isCommittedOrActual;
});
});
const filteredTransactions = computed(() => {
let filtered = allRecurringTransactions.value;
if (activeFilter.value === "income") {
filtered = recurringIncomeTransactions.value;
} else if (activeFilter.value === "expenses") {
filtered = recurringExpenseTransactions.value;
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.category.toLowerCase().includes(query) ||
t.notes?.toLowerCase().includes(query) ||
t.program?.toLowerCase().includes(query)
);
}
return filtered;
});
// Count computed properties
const recurringIncomeCount = computed(
() => recurringIncomeTransactions.value.length
);
const recurringExpenseCount = computed(
() => recurringExpenseTransactions.value.length
);
// Financial calculations
const monthlyRecurringIncome = computed(() => {
return activeIncomeTransactions.value.reduce((sum, t) => {
return sum + getMonthlyAmount(t);
}, 0);
});
const monthlyRecurringExpenses = computed(() => {
const now = new Date();
return allRecurringTransactions.value
.filter((t) => {
if (t.amount >= 0) return false;
const startDate = new Date(t.date);
const endDate = t.endDate ? new Date(t.endDate) : new Date("2099-12-31");
return now >= startDate && now <= endDate;
})
.reduce((sum, t) => {
return sum + Math.abs(getMonthlyAmount(t));
}, 0);
});
const netMonthlyCashFlow = computed(() => {
return monthlyRecurringIncome.value - monthlyRecurringExpenses.value;
});
const netCashFlowColor = computed(() => {
if (netMonthlyCashFlow.value > 0) return "text-green-600";
if (netMonthlyCashFlow.value < 0) return "text-red-600";
return "text-gray-600";
});
// Helper functions
const getMonthlyAmount = (transaction) => {
if (transaction.type !== "recurring") return 0;
const frequencyMultiplier = {
weekly: 4.33,
biweekly: 2.17,
monthly: 1,
quarterly: 0.33,
custom: transaction.customMonths ? 12 / transaction.customMonths : 1,
};
return (
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1) *
transaction.probability
);
};
const formatDate = (date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const getCategoryBadgeClass = (category) => {
const categoryColorMap = {
"expense: need": "bg-red-100 text-red-800",
"expense: want": "bg-orange-100 text-orange-800",
income: "bg-green-100 text-green-800",
};
return (
categoryColorMap[category.toLowerCase()] || "bg-gray-100 text-gray-800"
);
};
const editTransaction = (transaction) => {
emit("edit-transaction", transaction);
};
</script>

View file

@ -0,0 +1,508 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div
class="relative top-20 mx-auto p-5 border-2 border-black w-96 shadow-lg bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-black">
{{ isEditing ? "Edit Transaction" : "Add New Transaction" }}
</h3>
<button @click="closeModal" class="text-black hover:text-red-600">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Description -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Description *
</label>
<input
v-model="form.description"
type="text"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter transaction description" />
</div>
<!-- Amount -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Amount *
</label>
<div class="flex space-x-2">
<input
v-model.number="form.amount"
type="number"
step="0.01"
required
class="flex-1 px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter amount" />
<select
v-model="form.currency"
class="px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="CAD">CAD $</option>
<option value="EUR">EUR </option>
</select>
</div>
<div
v-if="form.currency !== 'CAD' && form.amount"
class="mt-1 space-y-1">
<div class="text-sm text-black">
{{ form.amount > 0 ? "+" : ""
}}{{ formatCurrency(form.amount, form.currency) }} =
{{ form.amount > 0 ? "+" : ""
}}{{ formatCurrency(convertedAmount, "CAD") }} CAD
<span v-if="exchangeRateLoading" class="text-blue-600 ml-2">
Updating rate...
</span>
</div>
<div class="text-xs text-black">
<span v-if="lastRateUpdate">
Rate: {{ exchangeRates[form.currency].toFixed(5) }} ({{
formatTimeAgo(lastRateUpdate)
}})
</span>
<span v-if="exchangeRateError" class="text-orange-600 ml-2">
{{ exchangeRateError }}
</span>
</div>
</div>
<p class="text-xs text-black mt-1">
Positive for income, negative for expenses
</p>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Category *
</label>
<select
v-model="form.category"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Select category</option>
<option value="Expense: Need">Expense: Need</option>
<option value="Expense: Want">Expense: Want</option>
<option value="Income">Income</option>
</select>
</div>
<!-- Type -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Type *
</label>
<select
v-model="form.type"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="recurring">Recurring</option>
<option value="one-time">One-time</option>
</select>
</div>
<!-- Frequency (only for recurring) -->
<div v-if="form.type === 'recurring'">
<label class="block text-sm font-medium text-black mb-1">
Frequency *
</label>
<select
v-model="form.frequency"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="custom">Custom</option>
</select>
</div>
<!-- Custom months (only for custom frequency) -->
<div v-if="form.type === 'recurring' && form.frequency === 'custom'">
<label class="block text-sm font-medium text-black mb-1">
Every X Months *
</label>
<input
v-model.number="form.customMonths"
type="number"
min="1"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter number of months" />
</div>
<!-- Start Date -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Start Date *
</label>
<input
v-model="form.date"
type="date"
required
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- End Date (optional for recurring) -->
<div v-if="form.type === 'recurring'">
<label class="block text-sm font-medium text-black mb-1">
End Date (optional)
</label>
<input
v-model="form.endDate"
type="date"
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- Confirmed Status -->
<div class="flex items-center">
<input
v-model="form.isConfirmed"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-2 border-black" />
<label class="ml-2 block text-sm text-black">
Confirmed (check if this transaction is certain)
</label>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-black mb-1">
Notes
</label>
<textarea
v-model="form.notes"
rows="2"
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional notes"></textarea>
</div>
<!-- Business Percentage Split -->
<div class="border-t pt-4">
<label class="block text-sm font-medium text-black mb-3">
Business/Personal Split
</label>
<div class="space-y-3">
<!-- Slider -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-black">Machine Magic %</span>
<span class="text-sm font-medium text-blue-600">{{ form.businessPercentage }}%</span>
</div>
<input
v-model.number="form.businessPercentage"
type="range"
min="0"
max="100"
step="5"
class="w-full h-2 bg-black appearance-none cursor-pointer slider" />
</div>
<!-- Amount Breakdown -->
<div class="bg-white border-2 border-black p-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-black">Machine Magic:</span>
<span class="font-medium text-blue-600 ml-1">
{{ formatCurrency(businessAmount) }}
</span>
</div>
<div>
<span class="text-black">Personal:</span>
<span class="font-medium text-green-600 ml-1">
{{ formatCurrency(personalAmount) }}
</span>
</div>
</div>
<div class="text-xs text-black mt-2">
Only the personal portion ({{ formatCurrency(personalAmount) }}) affects your cash flow calculations
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center pt-4">
<!-- Delete button (only show when editing) -->
<div>
<button
v-if="isEditing"
type="button"
@click="handleDelete"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border-2 border-red-800 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
<!-- Cancel and Save buttons -->
<div class="flex space-x-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-black bg-white border-2 border-black hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-black">
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border-2 border-blue-800 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
{{ isEditing ? "Update" : "Add" }} Transaction
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
// Get access to the cashFlow composable
const cashFlow = useCashFlow();
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
transaction: {
type: Object,
default: null,
},
});
const emit = defineEmits(["close", "save", "delete"]);
const isEditing = computed(() => !!props.transaction);
const defaultForm = {
description: "",
amount: 0,
currency: "CAD",
category: "",
type: "recurring",
frequency: "monthly",
customMonths: 1,
date: new Date().toISOString().split("T")[0],
endDate: "",
probability: 1.0,
isConfirmed: true,
notes: "",
businessPercentage: 0, // Default to 0% business (100% personal)
};
const form = ref({ ...defaultForm });
// Exchange rates - will be updated from Wise API
const exchangeRates = ref({
CAD: 1.0,
EUR: 1.45, // Will be updated from Wise API
USD: 1.35, // Fallback rate
GBP: 1.65, // Fallback rate
});
const exchangeRateLoading = ref(false);
const exchangeRateError = ref("");
const lastRateUpdate = ref(null);
// Computed property for converted amount (always to CAD)
const convertedAmount = computed(() => {
if (!form.value.amount || form.value.currency === "CAD")
return form.value.amount;
return form.value.amount * exchangeRates.value[form.value.currency];
});
// Computed properties for business/personal split
const businessAmount = computed(() => {
const totalAmount = convertedAmount.value || 0;
return (totalAmount * form.value.businessPercentage) / 100;
});
const personalAmount = computed(() => {
const totalAmount = convertedAmount.value || 0;
return totalAmount - businessAmount.value;
});
// Format currency
const formatCurrency = (amount, currency = "CAD") => {
const symbols = {
CAD: "$",
USD: "$",
EUR: "€",
GBP: "£",
};
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount || 0);
};
// Format time ago for exchange rate timestamps
const formatTimeAgo = (date) => {
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return "today";
};
// Fetch real-time exchange rates from Wise API
const fetchExchangeRates = async (sourceCurrency) => {
if (sourceCurrency === "CAD") return; // No need to fetch if already CAD
exchangeRateLoading.value = true;
exchangeRateError.value = "";
try {
const response = await fetch(
`/api/wise/exchange-rates?source=${sourceCurrency}&target=CAD`
);
if (response.ok) {
const data = await response.json();
exchangeRates.value[sourceCurrency] = data.rate;
lastRateUpdate.value = new Date(data.timestamp);
if (data.rateType === "fallback") {
exchangeRateError.value = "Using fallback rate - Wise API unavailable";
}
} else {
throw new Error("Failed to fetch exchange rate");
}
} catch (error) {
console.error("Exchange rate fetch error:", error);
exchangeRateError.value = "Failed to update exchange rate";
} finally {
exchangeRateLoading.value = false;
}
};
watch(
() => props.isOpen,
(newVal) => {
if (newVal) {
if (props.transaction) {
form.value = {
...defaultForm,
...props.transaction,
currency: props.transaction.originalCurrency || props.transaction.currency || "CAD",
amount: props.transaction.originalAmount || props.transaction.totalAmount || props.transaction.amount,
businessPercentage: props.transaction.businessPercentage || 0,
date: new Date(props.transaction.date).toISOString().split("T")[0],
endDate: props.transaction.endDate
? new Date(props.transaction.endDate).toISOString().split("T")[0]
: "",
};
} else {
// For new transactions
form.value = { ...defaultForm };
}
// Fetch exchange rates when modal opens with non-CAD currency
if (form.value.currency !== "CAD") {
fetchExchangeRates(form.value.currency);
}
}
}
);
// Watch for currency changes to fetch new exchange rates
watch(
() => form.value.currency,
(newCurrency) => {
if (newCurrency !== "CAD") {
fetchExchangeRates(newCurrency);
}
}
);
const closeModal = () => {
emit("close");
};
const handleDelete = () => {
if (props.transaction?.id) {
emit("delete", props.transaction.id);
}
closeModal();
};
const handleSubmit = () => {
// Convert amount to CAD for storage using real-time exchange rates
const totalAmountInCAD =
form.value.currency === "CAD"
? form.value.amount
: form.value.amount * exchangeRates.value[form.value.currency];
// Calculate personal portion (what affects personal cash flow)
const personalAmountInCAD = personalAmount.value;
const transactionData = {
...form.value,
id: props.transaction?.id || `transaction_${Date.now()}`,
amount: personalAmountInCAD, // Store personal amount only for cash flow calculations
totalAmount: totalAmountInCAD, // Store total amount for reference
businessAmount: businessAmount.value, // Store business amount for reference
originalAmount: form.value.amount, // Store original amount for reference
originalCurrency: form.value.currency, // Store original currency
date: new Date(form.value.date),
endDate: form.value.endDate ? new Date(form.value.endDate) : null,
status: form.value.isConfirmed ? "committed" : "projected",
};
emit("save", transactionData);
closeModal();
};
</script>
<style scoped>
/* Custom slider styling */
.slider {
background: linear-gradient(to right, #3b82f6 0%, #3b82f6 var(--percentage, 0%), #d1d5db var(--percentage, 0%), #d1d5db 100%);
}
.slider::-webkit-slider-thumb {
appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.slider::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
</style>

File diff suppressed because it is too large Load diff

38
app/pages/basic.vue Normal file
View file

@ -0,0 +1,38 @@
<template>
<div class="p-8 bg-blue-100">
<h1 class="text-4xl font-bold text-blue-800 mb-4">Basic Styling Test</h1>
<p class="text-lg text-gray-700 mb-4">This tests if Tailwind CSS is working properly.</p>
<div class="bg-white p-6 rounded-lg shadow-lg mb-4">
<h2 class="text-2xl font-semibold text-gray-800 mb-2">White Card</h2>
<p class="text-gray-600">If you can see styling, Tailwind is working!</p>
</div>
<button
@click="counter++"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">
Click Count: {{ counter }}
</button>
<div class="mt-4 p-4 bg-yellow-100 border-l-4 border-yellow-500">
<p class="text-yellow-700">
<strong>Test Results:</strong>
</p>
<ul class="list-disc list-inside text-yellow-600 mt-2">
<li>Background colors: {{ hasColors ? '✅ Working' : '❌ Not working' }}</li>
<li>Padding/margins: {{ hasSpacing ? '✅ Working' : '❌ Not working' }}</li>
<li>Typography: {{ hasTypography ? '✅ Working' : '❌ Not working' }}</li>
<li>Reactivity: {{ counter > 0 ? '✅ Working' : '❌ Click button to test' }}</li>
</ul>
</div>
</div>
</template>
<script setup>
const counter = ref(0);
// These would only be true if CSS is loading properly
const hasColors = ref(true); // We'll assume true for now
const hasSpacing = ref(true);
const hasTypography = ref(true);
</script>

287
app/pages/cashflow.vue Normal file
View file

@ -0,0 +1,287 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Cash Flow Analysis</h1>
<p class="text-gray-600 mt-2">Detailed cash flow projections and scenario analysis</p>
</div>
<!-- Navigation Tabs -->
<div class="mb-8">
<UTabs :items="tabs" v-model="activeTab">
<template #projections>
<div class="space-y-6">
<!-- Chart Controls -->
<div class="flex justify-between items-center">
<div class="flex space-x-4">
<UButton
@click="projectionType = 'weekly'"
:variant="projectionType === 'weekly' ? 'solid' : 'outline'"
size="sm">
Weekly View
</UButton>
<UButton
@click="projectionType = 'monthly'"
:variant="projectionType === 'monthly' ? 'solid' : 'outline'"
size="sm">
Monthly View
</UButton>
</div>
<div class="flex space-x-2">
<UButton @click="exportData" variant="outline" size="sm">
Export Data
</UButton>
</div>
</div>
<!-- Cash Flow Chart -->
<UCard>
<CashFlowChart
:data="projectionData"
:critical-balance="criticalBalance"
@weeks-changed="updateProjectionPeriod" />
</UCard>
<!-- Summary Table -->
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Projection Summary</h3>
</template>
<UTable
:rows="projectionSummary"
:columns="summaryColumns" />
</UCard>
</div>
</template>
<template #scenarios>
<div class="space-y-6">
<!-- Scenario Comparison -->
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Runway Scenarios</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div
v-for="(scenario, key) in runwayScenarios"
:key="key"
class="text-center p-4 border rounded-lg">
<h4 class="font-semibold capitalize mb-2">{{ key }}</h4>
<p class="text-2xl font-bold" :class="getScenarioColor(scenario)">
{{ formatRunwayDays(scenario) }}
</p>
<p class="text-xs text-gray-500 mt-1">{{ getScenarioDescription(key) }}</p>
</div>
</div>
</UCard>
<!-- Risk Assessment -->
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Risk Assessment</h3>
</template>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h4 class="font-medium">Current Risk Level</h4>
<p class="text-sm text-gray-600">Based on realistic scenario</p>
</div>
<UBadge
:color="getRiskBadgeColor(cashFlow.riskLevel.value)"
variant="subtle"
size="lg">
{{ cashFlow.riskLevel.value.toUpperCase() }}
</UBadge>
</div>
<div class="space-y-2">
<h5 class="font-medium">Recommendations:</h5>
<ul class="list-disc list-inside space-y-1 text-sm text-gray-600">
<li v-for="rec in riskRecommendations" :key="rec">{{ rec }}</li>
</ul>
</div>
</div>
</UCard>
</div>
</template>
<template #transactions>
<RecurringTransactionManager
:transactions="cashFlow.transactions.value"
@add-transaction="handleAddTransaction"
@edit-transaction="handleEditTransaction" />
</template>
</UTabs>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Transaction } from "~/types/cashflow";
// SEO
useSeoMeta({
title: 'Cash Flow Analysis - Faber Finances',
description: 'Detailed cash flow projections and scenario analysis'
});
// Initialize cash flow composable
const cashFlow = useCashFlow();
// Reactive state
const activeTab = ref(0);
const projectionType = ref<'weekly' | 'monthly'>('weekly');
const projectionPeriod = ref(13);
const criticalBalance = ref(50000);
// Tab configuration
const tabs = [
{ slot: 'projections', label: 'Projections' },
{ slot: 'scenarios', label: 'Scenarios' },
{ slot: 'transactions', label: 'Transactions' }
];
// Table columns
const summaryColumns = [
{ key: 'period', label: 'Period' },
{ key: 'inflow', label: 'Inflow' },
{ key: 'outflow', label: 'Outflow' },
{ key: 'netFlow', label: 'Net Flow' },
{ key: 'balance', label: 'Ending Balance' }
];
// Computed properties
const projectionData = computed(() => {
if (projectionType.value === 'weekly') {
return cashFlow.generateWeeklyProjections(projectionPeriod.value);
} else {
return cashFlow.generateProjections(Math.ceil(projectionPeriod.value / 4.33));
}
});
const projectionSummary = computed(() => {
return projectionData.value.map((projection, index) => ({
period: projectionType.value === 'weekly'
? `Week ${index + 1}`
: projection.date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
inflow: formatCurrency(projection.inflow),
outflow: formatCurrency(projection.outflow),
netFlow: formatCurrency(projection.netFlow),
balance: formatCurrency(projection.projectedBalance)
}));
});
const runwayScenarios = computed(() => {
return cashFlow.calculateRunwayScenarios();
});
const riskRecommendations = computed(() => {
const risk = cashFlow.riskLevel.value;
const recommendations = [];
if (risk === 'critical') {
recommendations.push('Immediate cash injection required');
recommendations.push('Delay all non-essential expenses');
recommendations.push('Accelerate revenue collection');
recommendations.push('Consider emergency funding options');
} else if (risk === 'high') {
recommendations.push('Monitor cash flow weekly');
recommendations.push('Reduce discretionary spending');
recommendations.push('Focus on converting opportunities');
recommendations.push('Prepare contingency plans');
} else if (risk === 'medium') {
recommendations.push('Review monthly expenses for optimization');
recommendations.push('Build stronger revenue pipeline');
recommendations.push('Consider increasing cash reserves');
} else {
recommendations.push('Maintain current financial discipline');
recommendations.push('Consider strategic investments');
recommendations.push('Build reserves for future opportunities');
}
return recommendations;
});
// Methods
const updateProjectionPeriod = (weeks: number) => {
projectionPeriod.value = weeks;
};
const handleAddTransaction = () => {
console.log("Add transaction clicked");
// TODO: Implement transaction modal
};
const handleEditTransaction = (transaction: Transaction) => {
console.log("Edit transaction:", transaction);
// TODO: Implement transaction modal
};
const exportData = () => {
const data = {
projections: projectionData.value,
scenarios: runwayScenarios.value,
transactions: cashFlow.transactions.value,
currentBalance: cashFlow.currentBalance.value,
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `cashflow-export-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(url);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const formatRunwayDays = (days: number) => {
if (days === Infinity) return "∞";
if (days > 365) return `${Math.round(days / 365)} years`;
if (days > 30) return `${Math.round(days / 30)} months`;
return `${Math.round(days)} days`;
};
const getScenarioColor = (days: number) => {
if (days < 30) return "text-red-600";
if (days < 90) return "text-orange-600";
return "text-green-600";
};
const getScenarioDescription = (scenario: string) => {
switch (scenario) {
case 'conservative': return 'Only confirmed transactions';
case 'realistic': return 'Probability-weighted';
case 'optimistic': return 'All transactions at full value';
case 'cashOnly': return 'Current balance only';
default: return '';
}
};
const getRiskBadgeColor = (riskLevel: string) => {
switch (riskLevel) {
case "critical": return "red";
case "high": return "orange";
case "medium": return "yellow";
default: return "green";
}
};
// Initialize with some default balance if none exists
onMounted(() => {
if (cashFlow.currentBalance.value === 0) {
cashFlow.setCurrentBalance(50000);
}
});
</script>

813
app/pages/index.vue Normal file
View file

@ -0,0 +1,813 @@
<template>
<div class="min-h-screen bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Current Balance Card -->
<div class="bg-white border-2 border-black shadow-lg p-6">
<div class="text-center">
<p class="text-sm font-medium text-black">
Available Balance
</p>
<p class="text-2xl font-bold text-blue-600">
{{ formatCurrency(cashFlow?.startingBalance?.value || 0) }}
</p>
<button
@click="openBalanceModal"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 underline border-b border-blue-600">
Update Balances
</button>
</div>
</div>
<!-- Runway Card -->
<div class="bg-white border-2 border-black shadow-lg p-6">
<div class="text-center">
<p class="text-sm font-medium text-black">Runway</p>
<p class="text-2xl font-bold" :class="runwayColor">
{{ runwayText }}
</p>
</div>
</div>
<!-- Core Expenses Card -->
<div class="bg-white border-2 border-black shadow-lg p-6">
<div class="text-center">
<p class="text-sm font-medium text-black">
Core Expenses (Needs)
</p>
<p class="text-2xl font-bold text-red-600">
{{ formatCurrency(monthlyCoreExpenses) }}
</p>
</div>
</div>
<!-- Income Card -->
<div class="bg-white border-2 border-black shadow-lg p-6">
<div class="text-center">
<p class="text-sm font-medium text-black">Monthly Income</p>
<p class="text-2xl font-bold text-green-600">
{{ formatCurrency(monthlyIncome) }}
</p>
</div>
</div>
</div>
<!-- Scenario Control Panel -->
<div class="bg-white border-2 border-black shadow-lg p-6 mb-8">
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div class="mb-4 sm:mb-0">
<h3 class="text-lg font-semibold text-black">
Financial Scenario
</h3>
<p class="text-sm text-black">
<span
class="font-medium"
:class="
scenario === 'interest' ? 'text-red-600' : 'text-green-600'
">
{{
scenario === "interest"
? "🔴 Current (Interest Payments)"
: "🟢 Bankruptcy (Surplus Payments)"
}}
</span>
- {{ scenarioDescription }}
</p>
</div>
<div
class="flex flex-col sm:flex-row sm:items-center space-y-4 sm:space-y-0 sm:space-x-4">
<!-- Scenario Toggle -->
<div class="flex items-center space-x-3">
<label class="text-sm font-medium text-black">Scenario:</label>
<select
v-model="scenario"
@change="updateScenario"
class="px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="interest">Interest Payments</option>
<option value="bankruptcy">Dynamic Surplus</option>
</select>
</div>
<!-- Dynamic Surplus Calculation Display (only show for bankruptcy scenario) -->
<div
v-if="scenario === 'bankruptcy'"
class="bg-white border-2 border-black px-3 py-2 text-sm">
<div class="font-medium text-black">
Surplus: {{ formatCurrency(calculatedSurplusPayment) }}/month
</div>
<div class="text-xs text-black">
Income: {{ formatCurrency(householdNetIncome) }} | Threshold:
{{ formatCurrency(surplusThreshold) }} | Available:
{{
formatCurrency(
Math.max(0, householdNetIncome - surplusThreshold)
)
}}
</div>
</div>
</div>
</div>
</div>
<!-- Transaction Modal -->
<TransactionModal
:is-open="isModalOpen"
:transaction="selectedTransaction"
@close="closeModal"
@save="saveTransaction"
@delete="handleDeleteTransactionFromModal" />
<!-- Delete Confirmation Modal -->
<div
v-if="showDeleteConfirmation"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div
class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">
Confirm Delete
</h3>
<button @click="cancelDelete" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600">
Are you sure you want to delete this transaction? This action cannot be undone.
</p>
<div v-if="transactionToDelete" class="mt-3 p-3 bg-gray-50 rounded">
<p class="text-sm font-medium">{{ transactionToDelete.description }}</p>
<p class="text-sm text-gray-500">
{{ formatCurrency(transactionToDelete.amount) }} - {{ transactionToDelete.category }}
</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
@click="cancelDelete"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500">
Cancel
</button>
<button
@click="confirmDelete"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500">
Delete Transaction
</button>
</div>
</div>
</div>
</div>
<!-- Balance Modal -->
<BalanceModal
:is-open="isBalanceModalOpen"
:current-balance="cashFlow?.currentBalance?.value || 0"
@close="closeBalanceModal"
@update="updateBalance" />
<!-- Projected Transactions (Next 13 Weeks) -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div
class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold">
All Transactions - Next 13 Weeks
</h3>
<button
@click="handleAddTransaction"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 border-2 border-blue-800 flex items-center">
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Transaction
</button>
</div>
<div class="overflow-x-auto" v-if="projectedTransactions.length > 0">
<table class="w-full">
<thead class="bg-black text-white">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Date
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Description
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Category
</th>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
Amount
</th>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
Running Balance
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Notes
</th>
<th
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-black">
<tr
v-for="transaction in projectedTransactions"
:key="`${transaction.id}-${transaction.date}`"
class="hover:bg-blue-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm text-black">
{{ formatDate(transaction.date) }}
</td>
<td class="px-6 py-4">
<div>
<p class="text-sm font-medium text-black">
{{ transaction.description }}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-black">{{
transaction.category
}}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div
:class="
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
">
<p class="text-sm font-medium">
{{ formatCurrency(transaction.amount) }}
</p>
<!-- Show business split info if applicable -->
<div v-if="transaction.businessPercentage > 0" class="text-xs text-gray-500 space-y-0.5">
<div>Personal: {{ formatCurrency(transaction.amount) }}</div>
<div class="text-blue-600">Business: {{ formatCurrency(transaction.businessAmount || 0) }} ({{ transaction.businessPercentage }}%)</div>
<div class="font-medium">Total: {{ formatCurrency(transaction.totalAmount || transaction.amount) }}</div>
</div>
<!-- Show original currency info if different and no business split -->
<p
v-else-if="
transaction.originalCurrency &&
transaction.originalCurrency !== 'CAD'
"
class="text-xs text-gray-500">
({{
formatCurrency(
transaction.originalAmount,
transaction.originalCurrency
)
}})
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<p
class="text-sm font-bold"
:class="
transaction.runningBalance < 0
? 'text-red-600'
: 'text-black'
">
{{ formatCurrency(transaction.runningBalance) }}
</p>
</td>
<td class="px-6 py-4 text-left">
<p class="text-sm text-black max-w-xs truncate" :title="transaction.notes">
{{ transaction.notes || '-' }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex justify-center space-x-2">
<button
@click="handleEditTransaction(transaction)"
class="text-blue-600 hover:text-blue-800 text-sm font-medium px-2 py-1 border border-blue-600 hover:bg-blue-50">
Edit
</button>
<button
@click="handleDeleteTransaction(transaction.id)"
class="text-red-600 hover:text-red-800 text-sm font-medium px-2 py-1 border border-red-600 hover:bg-red-50">
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state when no transactions -->
<div v-else class="text-center py-12">
<svg
class="h-12 w-12 mx-auto mb-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">
No transactions yet
</h3>
<p class="text-gray-600 mb-4">
Add your first transaction to start tracking your cash flow
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
// SEO
useSeoMeta({
title: "Faber Finances",
description: "$$$",
});
// Initialize cash flow composable
const cashFlow = useCashFlow();
// Reactive state
const isModalOpen = ref(false);
const selectedTransaction = ref(null);
const isBalanceModalOpen = ref(false);
const newBalance = ref(0);
const showDeleteConfirmation = ref(false);
const transactionToDelete = ref(null);
// Scenario state
const scenario = ref("interest"); // 'interest' or 'bankruptcy'
const interestTransactionId = ref("transaction_1755673351011"); // Store Interest transaction ID
// Surplus calculation constants
const surplusThreshold = 3318; // CAD threshold for 2-person household surplus calculation
// Calculate household net income from transactions (Personal account only)
const householdNetIncome = computed(() => {
if (!cashFlow?.transactions?.value) return 0;
return cashFlow.transactions.value
.filter((t) => t.amount > 0) // Only income transactions (positive amounts)
.reduce((total, transaction) => {
if (transaction.type === "recurring") {
// Calculate monthly income from recurring transactions
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
return (
total +
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1)
);
} else {
// For one-time transactions, don't include in monthly calculation
return total;
}
}, 0);
});
// Calculate dynamic surplus payment
const calculatedSurplusPayment = computed(() => {
if (householdNetIncome.value > surplusThreshold) {
const surplus = householdNetIncome.value - surplusThreshold;
return Math.round(surplus * 0.5); // 50% of surplus above threshold
}
return 0;
});
// Scenario descriptions
const scenarioDescription = computed(() => {
if (scenario.value === "interest") {
return "Paying $2,700/month in interest expenses";
} else {
return `Dynamic surplus based on income: ${formatCurrency(
calculatedSurplusPayment.value
)}/month`;
}
});
// Computed properties
const runwayText = computed(() => {
const days = cashFlow?.runwayDays?.value || 0;
if (days === Infinity) return "∞";
if (days > 365) return `${Math.round(days / 365)} years`;
if (days > 30) return `${Math.round(days / 30)} months`;
return `${Math.round(days)} days`;
});
const runwayColor = computed(() => {
const days = cashFlow?.runwayDays?.value || 0;
if (days < 30) return "text-red-600";
if (days < 90) return "text-orange-600";
return "text-green-600";
});
// Core Expenses (Need) - Monthly recurring expenses categorized as "Expense: Need"
const monthlyCoreExpenses = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return 0;
const transactions = cashFlow.filteredTransactions.value;
let totalCoreExpenses = 0;
transactions.forEach((transaction) => {
// Only include "Expense: Need" category expenses (negative amounts)
if (transaction.amount >= 0 || transaction.category !== "Expense: Need")
return;
if (transaction.type === "recurring") {
// Convert recurring transactions to monthly amounts
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
const monthlyAmount =
Math.abs(transaction.amount) *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
totalCoreExpenses += monthlyAmount * transaction.probability;
}
});
return totalCoreExpenses;
});
// Monthly Income - Monthly recurring income
const monthlyIncome = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return 0;
const transactions = cashFlow.filteredTransactions.value;
let totalIncome = 0;
transactions.forEach((transaction) => {
// Only include income (positive amounts) - accept any income category
if (transaction.amount <= 0) return;
if (transaction.type === "recurring") {
// Convert recurring transactions to monthly amounts
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
const monthlyAmount =
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
totalIncome += monthlyAmount * transaction.probability;
}
});
return totalIncome;
});
const projectedTransactions = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return [];
const transactions = cashFlow.filteredTransactions.value;
const projectedTransactions = [];
const today = new Date();
const endDate = new Date(today.getTime() + 13 * 7 * 24 * 60 * 60 * 1000); // 13 weeks from now
transactions.forEach((transaction) => {
if (transaction.type === "recurring") {
// Generate recurring transactions for the next 13 weeks
const startDate = new Date(
Math.max(today.getTime(), new Date(transaction.date).getTime())
);
const transactionEndDate = transaction.endDate
? new Date(transaction.endDate)
: endDate;
let currentDate = new Date(startDate);
while (currentDate <= endDate && currentDate <= transactionEndDate) {
projectedTransactions.push({
...transaction,
date: new Date(currentDate),
isProjected: true,
});
// Calculate next occurrence based on frequency
switch (transaction.frequency) {
case "weekly":
currentDate = new Date(
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000
);
break;
case "biweekly":
currentDate = new Date(
currentDate.getTime() + 14 * 24 * 60 * 60 * 1000
);
break;
case "monthly":
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
currentDate.getDate()
);
break;
case "quarterly":
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 3,
currentDate.getDate()
);
break;
case "custom":
const months = transaction.customMonths || 1;
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + months,
currentDate.getDate()
);
break;
default:
// Default to monthly
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
currentDate.getDate()
);
}
}
} else if (transaction.type === "one-time") {
// Include one-time transactions that fall within the next 13 weeks
const transactionDate = new Date(transaction.date);
if (transactionDate >= today && transactionDate <= endDate) {
projectedTransactions.push({
...transaction,
date: transactionDate,
isProjected: true,
});
}
}
});
// Sort by date (earliest first)
const sorted = projectedTransactions.sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
// Calculate running balance for each transaction
// Start with account-specific starting balance (without projections)
let runningBalance = cashFlow?.startingBalance?.value || 0;
return sorted.map((transaction) => {
runningBalance += transaction.amount;
return {
...transaction,
runningBalance,
};
});
});
// Methods
const handleAddTransaction = () => {
selectedTransaction.value = null;
isModalOpen.value = true;
};
const handleEditTransaction = (transaction) => {
selectedTransaction.value = transaction;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedTransaction.value = null;
};
const saveTransaction = (transactionData) => {
if (!cashFlow) return;
const currentTransactions = cashFlow.transactions.value;
if (selectedTransaction.value) {
// Edit existing transaction
const index = currentTransactions.findIndex(
(t) => t.id === selectedTransaction.value.id
);
if (index !== -1) {
currentTransactions[index] = transactionData;
}
} else {
// Add new transaction
currentTransactions.push(transactionData);
}
cashFlow.updateTransactions(currentTransactions);
};
const openBalanceModal = () => {
newBalance.value = cashFlow?.currentBalance?.value || 0;
isBalanceModalOpen.value = true;
};
const closeBalanceModal = () => {
isBalanceModalOpen.value = false;
};
const updateBalance = (totalBalance) => {
if (cashFlow) {
cashFlow.setCurrentBalance(totalBalance);
}
closeBalanceModal();
};
// Delete transaction methods
const handleDeleteTransaction = (transactionId) => {
const transaction = cashFlow.transactions.value.find(t => t.id === transactionId);
if (transaction) {
transactionToDelete.value = transaction;
showDeleteConfirmation.value = true;
}
};
const handleDeleteTransactionFromModal = async (transactionId) => {
try {
await cashFlow.deleteTransaction(transactionId);
} catch (error) {
console.error('Failed to delete transaction:', error);
// You could add user notification here
}
};
const cancelDelete = () => {
showDeleteConfirmation.value = false;
transactionToDelete.value = null;
};
const confirmDelete = async () => {
if (transactionToDelete.value) {
try {
await cashFlow.deleteTransaction(transactionToDelete.value.id);
cancelDelete();
} catch (error) {
console.error('Failed to delete transaction:', error);
// You could add user notification here
}
}
};
const formatCurrency = (amount, currency = "CAD") => {
// For CAD amounts in the main dashboard, show no decimals for cleaner look
const decimals = currency === "CAD" ? 0 : 2;
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount || 0);
};
const formatDate = (date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
};
// Scenario management methods (Personal account only)
const updateScenario = async () => {
if (!cashFlow?.transactions?.value) return;
// All transactions are now personal
const currentTransactions = [...cashFlow.transactions.value];
if (scenario.value === "bankruptcy") {
// Switch to bankruptcy scenario
// 1. Remove Interest transaction
const filteredTransactions = currentTransactions.filter(
(t) => !(t.description === "Interest" && t.amount === -2700)
);
// 2. Add dynamic Surplus Payment transaction (only if surplus > 0)
if (calculatedSurplusPayment.value > 0) {
const surplusTransaction = {
id: "bankruptcy_surplus_payment_transaction",
description: "Bankruptcy Surplus Payment",
amount: -calculatedSurplusPayment.value,
category: "Expense: Need",
type: "recurring",
frequency: "monthly",
date: new Date(),
probability: 1.0,
isConfirmed: true,
status: "committed",
notes: `Dynamic surplus: ${formatCurrency(
householdNetIncome.value
)} income - ${formatCurrency(
surplusThreshold
)} threshold = ${formatCurrency(
householdNetIncome.value - surplusThreshold
)} * 50%`,
};
filteredTransactions.push(surplusTransaction);
}
await cashFlow.updateTransactions(filteredTransactions);
} else {
// Switch to interest scenario
// 1. Remove Surplus Payment transaction
const filteredTransactions = currentTransactions.filter(
(t) => t.description !== "Bankruptcy Surplus Payment"
);
// 2. Add back Interest transaction
const interestTransaction = {
id: interestTransactionId.value,
description: "Interest",
amount: -2700,
category: "Expense: Need",
type: "recurring",
frequency: "monthly",
date: new Date("2025-08-29"),
probability: 1.0,
isConfirmed: true,
status: "committed",
notes: "",
};
filteredTransactions.push(interestTransaction);
await cashFlow.updateTransactions(filteredTransactions);
}
};
// Update surplus payment when income changes (watch for transaction changes, Personal only)
watch(
() => calculatedSurplusPayment.value,
async (newSurplus) => {
if (
scenario.value === "bankruptcy" &&
cashFlow?.transactions?.value
) {
const currentTransactions = [...cashFlow.transactions.value];
const surplusIndex = currentTransactions.findIndex(
(t) => t.description === "Bankruptcy Surplus Payment"
);
if (newSurplus > 0) {
if (surplusIndex !== -1) {
// Update existing surplus payment amount
currentTransactions[surplusIndex].amount = -newSurplus;
currentTransactions[
surplusIndex
].notes = `Dynamic surplus: ${formatCurrency(
householdNetIncome.value
)} income - ${formatCurrency(
surplusThreshold
)} threshold = ${formatCurrency(
householdNetIncome.value - surplusThreshold
)} * 50%`;
await cashFlow.updateTransactions(currentTransactions);
} else {
// Add new surplus payment if it doesn't exist
await updateScenario();
}
} else {
// Remove surplus payment if surplus becomes 0
if (surplusIndex !== -1) {
currentTransactions.splice(surplusIndex, 1);
await cashFlow.updateTransactions(currentTransactions);
}
}
}
}
);
// Initialize - balance will be loaded from localStorage by the composable
</script>

285
app/pages/migrate.vue Normal file
View file

@ -0,0 +1,285 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Data Migration</h1>
<p class="text-gray-600 mt-2">Migrate your data from localStorage to MongoDB</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<!-- Status -->
<div v-if="status" class="mb-6 p-4 rounded" :class="statusClass">
{{ status }}
</div>
<!-- Migration Section -->
<div class="mb-8">
<h3 class="text-lg font-semibold mb-4">1. Auto-Migration from localStorage</h3>
<button
@click="migrateFromLocalStorage"
:disabled="loading"
class="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
{{ loading ? 'Migrating...' : 'Migrate from localStorage' }}
</button>
<p class="text-sm text-gray-600 mt-2">
This will automatically detect and migrate any existing localStorage data.
</p>
</div>
<!-- Manual Import Section -->
<div class="mb-8">
<h3 class="text-lg font-semibold mb-4">2. Manual Data Import</h3>
<!-- Current Balance -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Current Balance (CAD $)
</label>
<input
v-model.number="manualData.balance"
type="number"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="0.00" />
</div>
<!-- Transactions JSON -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Transactions JSON
</label>
<textarea
v-model="manualData.transactionsJson"
rows="10"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Paste your transactions JSON array here"></textarea>
<p class="text-sm text-gray-600 mt-1">
Paste the JSON array of your transactions (the data you provided earlier)
</p>
</div>
<button
@click="migrateManualData"
:disabled="loading"
class="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
{{ loading ? 'Importing...' : 'Import Manual Data' }}
</button>
</div>
<!-- Current Data Display -->
<div class="mb-8">
<h3 class="text-lg font-semibold mb-4">3. Current Data Status</h3>
<button
@click="loadCurrentData"
:disabled="loading"
class="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded mb-4">
{{ loading ? 'Loading...' : 'Refresh Data Status' }}
</button>
<div class="bg-gray-50 p-4 rounded">
<p><strong>Current Balance:</strong> {{ formatCurrency(currentData.balance) }}</p>
<p><strong>Transactions:</strong> {{ currentData.transactions.length }} items</p>
<div v-if="currentData.transactions.length > 0" class="mt-2">
<p class="text-sm font-medium">Sample transactions:</p>
<ul class="text-sm text-gray-600 mt-1">
<li v-for="transaction in currentData.transactions.slice(0, 3)" :key="transaction.id">
{{ transaction.description }}: {{ formatCurrency(transaction.amount) }}
</li>
</ul>
</div>
</div>
</div>
<!-- Navigation -->
<div class="pt-4 border-t">
<NuxtLink
to="/"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
Back to Dashboard
</NuxtLink>
</div>
</div>
</div>
</div>
</template>
<script setup>
// Page metadata
useSeoMeta({
title: 'Data Migration - Faber Finances',
description: 'Migrate your financial data'
});
// State
const loading = ref(false);
const status = ref('');
const statusClass = ref('');
const manualData = ref({
balance: 0,
transactionsJson: ''
});
const currentData = ref({
balance: 0,
transactions: []
});
// Load current data status
const loadCurrentData = async () => {
loading.value = true;
try {
// Load balance
const balanceResponse = await fetch('/api/balances');
if (balanceResponse.ok) {
const balanceData = await balanceResponse.json();
currentData.value.balance = balanceData.currentBalance || 0;
}
// Load transactions
const transactionsResponse = await fetch('/api/transactions');
if (transactionsResponse.ok) {
const transactionsData = await transactionsResponse.json();
currentData.value.transactions = transactionsData;
}
setStatus('Data loaded successfully', 'success');
} catch (error) {
console.error('Failed to load current data:', error);
setStatus('Failed to load current data: ' + error.message, 'error');
} finally {
loading.value = false;
}
};
// Migrate from localStorage
const migrateFromLocalStorage = async () => {
loading.value = true;
try {
if (typeof window === 'undefined') {
throw new Error('Window not available');
}
const savedBalance = localStorage.getItem('cashflow_balance');
const savedTransactions = localStorage.getItem('cashflow_transactions');
if (!savedBalance && !savedTransactions) {
setStatus('No localStorage data found to migrate', 'warning');
return;
}
const migrationData = {
balance: savedBalance ? parseFloat(savedBalance) : 0,
transactions: savedTransactions ? JSON.parse(savedTransactions) : []
};
const response = await fetch('/api/migrate/localStorage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(migrationData)
});
if (!response.ok) {
throw new Error(`Migration failed: ${response.statusText}`);
}
const result = await response.json();
setStatus(
`Migration successful! Migrated ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
'success'
);
// Clear localStorage after successful migration
localStorage.removeItem('cashflow_balance');
localStorage.removeItem('cashflow_transactions');
localStorage.removeItem('account_balances');
// Refresh current data
await loadCurrentData();
} catch (error) {
console.error('Migration error:', error);
setStatus('Migration failed: ' + error.message, 'error');
} finally {
loading.value = false;
}
};
// Import manual data
const migrateManualData = async () => {
loading.value = true;
try {
let transactions = [];
if (manualData.value.transactionsJson.trim()) {
try {
transactions = JSON.parse(manualData.value.transactionsJson);
if (!Array.isArray(transactions)) {
throw new Error('Transactions must be an array');
}
} catch (error) {
throw new Error('Invalid JSON format: ' + error.message);
}
}
const migrationData = {
balance: manualData.value.balance || 0,
transactions: transactions
};
const response = await fetch('/api/migrate/localStorage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(migrationData)
});
if (!response.ok) {
throw new Error(`Import failed: ${response.statusText}`);
}
const result = await response.json();
setStatus(
`Import successful! Imported ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
'success'
);
// Clear form
manualData.value = { balance: 0, transactionsJson: '' };
// Refresh current data
await loadCurrentData();
} catch (error) {
console.error('Import error:', error);
setStatus('Import failed: ' + error.message, 'error');
} finally {
loading.value = false;
}
};
// Utility functions
const setStatus = (message, type) => {
status.value = message;
statusClass.value = type === 'success' ? 'bg-green-100 text-green-800' :
type === 'warning' ? 'bg-yellow-100 text-yellow-800' :
type === 'error' ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800';
// Clear status after 5 seconds
setTimeout(() => {
status.value = '';
}, 5000);
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD'
}).format(amount || 0);
};
// Load current data on mount
onMounted(() => {
loadCurrentData();
});
</script>

24
app/pages/minimal.vue Normal file
View file

@ -0,0 +1,24 @@
<template>
<div class="p-8">
<h1 class="text-3xl font-bold text-blue-600">Minimal Test Page</h1>
<p class="mt-4">If you can see this, basic routing and Vue are working!</p>
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded">
<p> Vue template rendering: Working</p>
<p> Tailwind CSS: Working</p>
<p> Nuxt routing: Working</p>
</div>
<div class="mt-4">
<button
@click="counter++"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Counter: {{ counter }}
</button>
</div>
</div>
</template>
<script setup>
const counter = ref(0);
</script>

96
app/pages/simple.vue Normal file
View file

@ -0,0 +1,96 @@
<template>
<div class="p-8">
<h1 class="text-2xl font-bold mb-4">Simple Test Page</h1>
<div class="space-y-4">
<p>This is a simple test page to verify basic functionality.</p>
<div class="p-4 bg-blue-50 border rounded">
<p>
Testing composable:
{{ composableWorking ? "Working" : "Not working" }}
</p>
<p>Current balance: {{ currentBalance }}</p>
<p>Transactions count: {{ transactionCount }}</p>
</div>
<div class="space-x-2">
<button
@click="setBalance"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Set Balance to $50,000
</button>
<button
@click="addSampleTransaction"
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
Add Sample Transaction
</button>
</div>
<div
v-if="errorMessage"
class="p-4 bg-red-50 border border-red-200 rounded">
<p class="text-red-700">Error: {{ errorMessage }}</p>
</div>
</div>
</div>
</template>
<script setup>
const errorMessage = ref("");
const composableWorking = ref(false);
const currentBalance = ref(0);
const transactionCount = ref(0);
let cashFlow = null;
try {
cashFlow = useCashFlow();
composableWorking.value = true;
// Watch for changes
watchEffect(() => {
if (cashFlow) {
currentBalance.value = cashFlow.currentBalance.value;
transactionCount.value = cashFlow.transactions.value.length;
}
});
} catch (error) {
errorMessage.value = `Failed to initialize cashFlow: ${error.message}`;
console.error("CashFlow error:", error);
}
const setBalance = () => {
try {
if (cashFlow) {
cashFlow.setCurrentBalance(50000);
}
} catch (error) {
errorMessage.value = `Failed to set balance: ${error.message}`;
}
};
const addSampleTransaction = () => {
try {
if (cashFlow) {
const sampleTransaction = {
id: `sample-${Date.now()}`,
date: new Date(),
description: "Sample Transaction",
amount: 1000,
category: "Income",
type: "one-time",
probability: 1.0,
isConfirmed: true,
};
cashFlow.updateTransactions([
...cashFlow.transactions.value,
sampleTransaction,
]);
}
} catch (error) {
errorMessage.value = `Failed to add transaction: ${error.message}`;
}
};
</script>

206
app/pages/test.vue Normal file
View file

@ -0,0 +1,206 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-6">Cash Flow Test Page</h1>
<!-- Test Results -->
<UCard class="mb-6">
<template #header>
<h2 class="text-lg font-semibold">Component Test Results</h2>
</template>
<div class="space-y-4">
<div
v-for="test in testResults"
:key="test.name"
class="flex items-center justify-between">
<span>{{ test.name }}</span>
<UBadge :color="test.passed ? 'green' : 'red'">
{{ test.passed ? "PASS" : "FAIL" }}
</UBadge>
</div>
</div>
</UCard>
<!-- Cash Flow Summary -->
<UCard class="mb-6">
<template #header>
<h2 class="text-lg font-semibold">Cash Flow Summary</h2>
</template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center">
<p class="text-sm text-gray-600">Current Balance</p>
<p class="text-xl font-bold">
{{ formatCurrency(cashFlow.currentBalance.value) }}
</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Transactions</p>
<p class="text-xl font-bold">
{{ cashFlow.transactions.value.length }}
</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Monthly Burn</p>
<p class="text-xl font-bold">
{{ formatCurrency(cashFlow.monthlyBurnRate.value) }}
</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Runway Days</p>
<p class="text-xl font-bold">
{{ Math.round(cashFlow.runwayDays.value) }}
</p>
</div>
</div>
</UCard>
<!-- Mini Chart Test -->
<UCard class="mb-6">
<template #header>
<h2 class="text-lg font-semibold">Chart Test (8 weeks)</h2>
</template>
<CashFlowChart :data="testProjections" :critical-balance="30000" />
</UCard>
<!-- Mini Transaction Manager Test -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold">Transaction Manager Test</h2>
</template>
<RecurringTransactionManager
:transactions="cashFlow.transactions.value"
@add-transaction="handleAddTransaction"
@edit-transaction="handleEditTransaction" />
</UCard>
</div>
</div>
</template>
<script setup lang="ts">
import type { Transaction } from "~/types/cashflow";
// SEO
useSeoMeta({
title: "Test Page",
description: "Testing cash flow functionality",
});
// Initialize cash flow
const cashFlow = useCashFlow();
// Test data
const testTransactions: Transaction[] = [
{
id: "test-1",
date: new Date("2024-01-01"),
description: "Test Salary",
amount: 10000,
category: "Income",
type: "recurring",
frequency: "monthly",
probability: 1.0,
isConfirmed: true,
status: "committed",
},
{
id: "test-2",
date: new Date("2024-01-01"),
description: "Test Rent",
amount: -2000,
category: "Expense: Need",
type: "recurring",
frequency: "monthly",
probability: 1.0,
isConfirmed: true,
status: "committed",
},
];
// Test results
const testResults = computed(() => {
const results = [];
// Test 1: Composable loads
try {
results.push({
name: "useCashFlow composable loads",
passed: !!cashFlow,
});
} catch {
results.push({
name: "useCashFlow composable loads",
passed: false,
});
}
// Test 2: Transaction updates work
try {
results.push({
name: "Transaction updates work",
passed: typeof cashFlow.updateTransactions === "function",
});
} catch {
results.push({
name: "Transaction updates work",
passed: false,
});
}
// Test 3: Projections generate
try {
const projections = cashFlow.generateWeeklyProjections(4);
results.push({
name: "Weekly projections generate",
passed: Array.isArray(projections) && projections.length > 0,
});
} catch {
results.push({
name: "Weekly projections generate",
passed: false,
});
}
// Test 4: Runway calculation works
try {
const runway = cashFlow.runwayDays.value;
results.push({
name: "Runway calculation works",
passed: typeof runway === "number",
});
} catch {
results.push({
name: "Runway calculation works",
passed: false,
});
}
return results;
});
const testProjections = computed(() => {
return cashFlow.generateWeeklyProjections(8);
});
const handleAddTransaction = () => {
console.log("Add transaction test");
};
const handleEditTransaction = (transaction: Transaction) => {
console.log("Edit transaction test:", transaction.description);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
// Initialize test data
onMounted(() => {
cashFlow.setCurrentBalance(75000);
cashFlow.updateTransactions(testTransactions);
});
</script>

92
app/types/cashflow.ts Normal file
View file

@ -0,0 +1,92 @@
export interface Transaction {
id?: string;
date: Date;
description: string;
amount: number; // Personal portion (after business split) in CAD
totalAmount?: number; // Total original amount in CAD
businessAmount?: number; // Business portion in CAD
businessPercentage?: number; // Percentage attributed to business (0-100)
originalAmount?: number; // Original amount before conversion
originalCurrency?: string; // Original currency (CAD, EUR, etc.)
currency?: string; // Current/display currency (for backward compatibility)
category: string;
type: "recurring" | "one-time";
program?: string;
status?: "actual" | "committed" | "projected";
fundType?: "restricted" | "unrestricted";
frequency?: "weekly" | "biweekly" | "monthly" | "quarterly" | "custom";
customMonths?: number;
endDate?: Date;
probability: number;
isConfirmed: boolean;
isTest?: boolean;
notes?: string;
projectedDate?: Date;
runningBalance?: number;
}
export interface RevenueOpportunity {
_id?: string;
id?: string;
source: string;
amount: number;
probability: number;
targetDate: Date;
stage: string;
notes?: string;
createdAt?: Date;
updatedAt?: Date;
}
export interface CashFlowProjection {
date: Date;
balance: number;
inflow: number;
outflow: number;
netFlow: number;
projectedBalance: number;
}
export interface RunwayScenarios {
conservative: number;
realistic: number;
optimistic: number;
cashOnly: number;
}
export interface CashPosition {
balance: number;
totalInflow: number;
totalOutflow: number;
netFlow: number;
}
export interface TransactionForecast {
month: Date;
recurring: Transaction[];
oneTime: Transaction[];
pipeline: Transaction[];
totalInflow: number;
totalOutflow: number;
runningBalance: number;
}
export interface CombinedCashFlow {
historical: CashFlowProjection[];
projected: CashFlowProjection[];
combined: CashFlowProjection[];
currentBalanceIndex: number;
}
export interface AffordabilityCheck {
canAfford: boolean;
currentAvailable?: number;
newBalance?: number;
currentRunwayWeeks?: number;
newRunwayWeeks?: number;
riskLevel?: string;
recommendation?: string;
reason?: string;
availableAmount?: number;
shortfall?: number;
}

6
nuxt.config.ts Normal file
View file

@ -0,0 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss']
})

13837
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "nuxt-app",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"mongodb": "^6.18.0",
"nuxt": "^4.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View file

@ -0,0 +1,42 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const collection = await getCollection('balances')
const balances = await collection.findOne({ type: 'current' })
if (!balances) {
// Return default balance structure if none exists
return {
currentBalance: 0,
accountBalances: {
manual: {
rbc_cad: 0,
td_cad: 0,
millennium_eur: 0
},
wise: {
jennie: [],
henry: []
},
lastWiseFetch: null,
lastManualUpdate: null
}
}
}
// Ensure timestamp fields exist
if (balances.accountBalances && !balances.accountBalances.lastWiseFetch && !balances.accountBalances.lastManualUpdate) {
balances.accountBalances.lastWiseFetch = null;
balances.accountBalances.lastManualUpdate = null;
}
return balances
} catch (error) {
console.error('Error fetching balances:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch balances'
})
}
})

View file

@ -0,0 +1,43 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const collection = await getCollection('balances')
// Get existing balance document to preserve timestamp data
const existingBalance = await collection.findOne({ type: 'current' })
const balanceData = {
type: 'current',
currentBalance: body.currentBalance,
accountBalances: {
manual: body.accountBalances?.manual || {},
wise: body.accountBalances?.wise || { jennie: [], henry: [] },
// Preserve existing timestamps if not explicitly provided
lastWiseFetch: body.accountBalances?.lastWiseFetch || existingBalance?.accountBalances?.lastWiseFetch || null,
lastManualUpdate: body.accountBalances?.lastManualUpdate || existingBalance?.accountBalances?.lastManualUpdate || null
},
updatedAt: new Date()
}
// Upsert the balance record
const result = await collection.replaceOne(
{ type: 'current' },
balanceData,
{ upsert: true }
)
return {
success: true,
modifiedCount: result.modifiedCount,
upsertedCount: result.upsertedCount
}
} catch (error) {
console.error('Error updating balances:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update balances'
})
}
})

View file

@ -0,0 +1,69 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const data = await readBody(event)
// Migrate transactions
if (data.transactions && data.transactions.length > 0) {
const transactionsCollection = await getCollection('transactions')
// Clear existing transactions
await transactionsCollection.deleteMany({})
// Convert and insert transactions
const mongoTransactions = data.transactions.map(transaction => ({
...transaction,
_id: transaction.id,
id: undefined,
date: new Date(transaction.date),
endDate: transaction.endDate ? new Date(transaction.endDate) : null,
createdAt: new Date(),
updatedAt: new Date()
}))
await transactionsCollection.insertMany(mongoTransactions)
}
// Migrate balance
if (data.balance !== undefined) {
const balancesCollection = await getCollection('balances')
const balanceData = {
type: 'current',
currentBalance: data.balance,
accountBalances: data.accountBalances || {
manual: {
rbc_cad: 0,
td_cad: 0,
millennium_eur: 0
},
wise: {
jennie: [],
henry: []
}
},
updatedAt: new Date()
}
await balancesCollection.replaceOne(
{ type: 'current' },
balanceData,
{ upsert: true }
)
}
return {
success: true,
message: 'Data migrated successfully',
transactionsCount: data.transactions?.length || 0,
balance: data.balance || 0
}
} catch (error) {
console.error('Error migrating localStorage data:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to migrate data'
})
}
})

View file

@ -0,0 +1,29 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const id = getRouterParam(event, 'id')
const collection = await getCollection('transactions')
// Delete the transaction
const result = await collection.deleteOne({ _id: id })
if (result.deletedCount === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Transaction not found'
})
}
return {
success: true,
deletedCount: result.deletedCount
}
} catch (error) {
console.error('Error deleting transaction:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to delete transaction'
})
}
})

View file

@ -0,0 +1,40 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const collection = await getCollection('transactions')
// Update document
const updateData = {
...body,
_id: id,
id: undefined,
updatedAt: new Date()
}
const result = await collection.replaceOne(
{ _id: id },
updateData
)
if (result.matchedCount === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Transaction not found'
})
}
return {
success: true,
modifiedCount: result.modifiedCount
}
} catch (error) {
console.error('Error updating transaction:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update transaction'
})
}
})

View file

@ -0,0 +1,42 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const transactions = await readBody(event)
const collection = await getCollection('transactions')
// Clear existing transactions and insert new ones
await collection.deleteMany({})
if (transactions.length > 0) {
// Convert transactions for MongoDB
const mongoTransactions = transactions.map(transaction => ({
...transaction,
_id: transaction.id,
id: undefined,
date: new Date(transaction.date),
endDate: transaction.endDate ? new Date(transaction.endDate) : null,
createdAt: new Date(),
updatedAt: new Date()
}))
const result = await collection.insertMany(mongoTransactions)
return {
success: true,
insertedCount: result.insertedCount
}
}
return {
success: true,
insertedCount: 0
}
} catch (error) {
console.error('Error bulk updating transactions:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to bulk update transactions'
})
}
})

View file

@ -0,0 +1,23 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const collection = await getCollection('transactions')
const transactions = await collection.find({}).toArray()
// Convert MongoDB _id to id for frontend compatibility
const formattedTransactions = transactions.map(transaction => ({
...transaction,
id: transaction._id.toString(),
_id: undefined
}))
return formattedTransactions
} catch (error) {
console.error('Error fetching transactions:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch transactions'
})
}
})

View file

@ -0,0 +1,30 @@
import { getCollection } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const collection = await getCollection('transactions')
// Convert id to _id for MongoDB
const transactionData = {
...body,
_id: body.id,
id: undefined,
createdAt: new Date(),
updatedAt: new Date()
}
const result = await collection.insertOne(transactionData)
return {
success: true,
id: result.insertedId.toString()
}
} catch (error) {
console.error('Error creating transaction:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to create transaction'
})
}
})

View file

@ -0,0 +1,70 @@
export default defineEventHandler(async (event) => {
const profile = getRouterParam(event, 'profile') // 'jennie' or 'henry'
// Get API token from environment variables
const apiToken = profile === 'jennie'
? process.env.WISE_API_KEY_JENNIE
: process.env.WISE_API_KEY_HENRY
if (!apiToken) {
throw createError({
statusCode: 400,
statusMessage: `Wise API token not configured for ${profile}`
})
}
try {
// First, get the profile ID
const profileResponse = await fetch('https://api.wise.com/v1/profiles', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
})
if (!profileResponse.ok) {
throw new Error(`Profile fetch failed: ${profileResponse.statusText}`)
}
const profiles = await profileResponse.json()
const personalProfile = profiles.find(p => p.type === 'personal')
if (!personalProfile) {
throw new Error('Personal profile not found')
}
// Get balances for the profile
const balancesResponse = await fetch(`https://api.wise.com/v4/profiles/${personalProfile.id}/balances?types=STANDARD`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
})
if (!balancesResponse.ok) {
throw new Error(`Balances fetch failed: ${balancesResponse.statusText}`)
}
const balances = await balancesResponse.json()
// Filter and format the balances - include all currencies
const formattedBalances = balances
.filter(balance => balance.amount) // Only filter out balances without amount data
.map(balance => ({
currency: balance.amount.currency,
value: {
value: balance.amount.value
}
}))
return formattedBalances
} catch (error) {
console.error(`Wise API error for ${profile}:`, error)
throw createError({
statusCode: 500,
statusMessage: `Failed to fetch Wise balances: ${error.message}`
})
}
})

View file

@ -0,0 +1,80 @@
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const { source = 'EUR', target = 'CAD' } = query
// Get API token from environment variables (use Jennie's token by default)
const apiToken = process.env.WISE_API_KEY_JENNIE
if (!apiToken) {
throw createError({
statusCode: 400,
statusMessage: 'Wise API token not configured'
})
}
try {
// First get the profile ID
const profileResponse = await fetch('https://api.wise.com/v1/profiles', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
})
if (!profileResponse.ok) {
throw new Error(`Profile fetch failed: ${profileResponse.statusText}`)
}
const profiles = await profileResponse.json()
const personalProfile = profiles.find(p => p.type === 'personal')
if (!personalProfile) {
throw new Error('Personal profile not found')
}
// Get exchange rates
const ratesResponse = await fetch(`https://api.wise.com/v1/rates?source=${source}&target=${target}`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
})
if (!ratesResponse.ok) {
throw new Error(`Exchange rates fetch failed: ${ratesResponse.statusText}`)
}
const rates = await ratesResponse.json()
// Return the rate and timestamp
return {
source,
target,
rate: rates[0]?.rate || null,
timestamp: new Date().toISOString(),
rateType: rates[0]?.type || 'unknown'
}
} catch (error) {
console.error(`Wise exchange rate API error:`, error)
// Fallback to hardcoded rate if API fails
const fallbackRates = {
'EUR-CAD': 1.45,
'USD-CAD': 1.35,
'GBP-CAD': 1.65
}
const fallbackKey = `${source}-${target}`
const fallbackRate = fallbackRates[fallbackKey] || 1.0
return {
source,
target,
rate: fallbackRate,
timestamp: new Date().toISOString(),
rateType: 'fallback',
error: 'Using fallback rate due to API error'
}
}
})

41
server/utils/db.js Normal file
View file

@ -0,0 +1,41 @@
import { MongoClient } from 'mongodb'
let client = null
let db = null
export async function connectToDatabase() {
if (db) {
return db
}
const uri = process.env.MONGO_URI
if (!uri) {
throw new Error('MONGO_URI environment variable is not set')
}
try {
client = new MongoClient(uri)
await client.connect()
db = client.db('faber-finances')
console.log('Connected to MongoDB')
return db
} catch (error) {
console.error('Failed to connect to MongoDB:', error)
throw error
}
}
export async function getCollection(name) {
const database = await connectToDatabase()
return database.collection(name)
}
// Close connection (useful for cleanup)
export async function closeDatabaseConnection() {
if (client) {
await client.close()
client = null
db = null
console.log('Disconnected from MongoDB')
}
}

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}