diff --git a/src/lib/components/EnhancedBalance.svelte b/src/lib/components/EnhancedBalance.svelte index 04abd00..7b03779 100644 --- a/src/lib/components/EnhancedBalance.svelte +++ b/src/lib/components/EnhancedBalance.svelte @@ -43,16 +43,6 @@ // Recalculate when debtData changes singleDebtUser = getSingleDebtUser(); shouldShowIntegratedView = singleDebtUser !== null; - - // Temporary debug logging - if (!loading) { - console.log('🔍 Debug Info:'); - console.log('- debtData:', debtData); - console.log('- whoOwesMe length:', debtData.whoOwesMe.length); - console.log('- whoIOwe length:', debtData.whoIOwe.length); - console.log('- singleDebtUser:', singleDebtUser); - console.log('- shouldShowIntegratedView:', shouldShowIntegratedView); - } } diff --git a/src/lib/utils/categories.ts b/src/lib/utils/categories.ts index 6da72b7..499d1d9 100644 --- a/src/lib/utils/categories.ts +++ b/src/lib/utils/categories.ts @@ -22,6 +22,10 @@ export const PAYMENT_CATEGORIES = { fun: { name: 'Fun', emoji: '🎉' + }, + settlement: { + name: 'Settlement', + emoji: '🤝' } } as const; diff --git a/src/lib/utils/settlements.ts b/src/lib/utils/settlements.ts new file mode 100644 index 0000000..9ed4d8a --- /dev/null +++ b/src/lib/utils/settlements.ts @@ -0,0 +1,63 @@ +// Utility functions for identifying and handling settlement payments + +/** + * Identifies if a payment is a settlement payment based on category + */ +export function isSettlementPayment(payment: any): boolean { + if (!payment) return false; + + // Check if category is settlement + return payment.category === 'settlement'; +} + +/** + * Gets the settlement icon for settlement payments + */ +export function getSettlementIcon(): string { + return '🤝'; // Handshake emoji for settlements +} + +/** + * Gets appropriate styling classes for settlement payments + */ +export function getSettlementClasses(payment: any): string[] { + if (!isSettlementPayment(payment)) { + return []; + } + + return ['settlement-payment']; +} + +/** + * Gets settlement-specific display text + */ +export function getSettlementDisplayText(payment: any): string { + if (!isSettlementPayment(payment)) { + return ''; + } + + return 'Settlement'; +} + +/** + * Gets the other user in a settlement (the one who didn't pay) + */ +export function getSettlementReceiver(payment: any): string { + if (!isSettlementPayment(payment) || !payment.splits) { + return ''; + } + + // Find the user who has a positive amount (the receiver) + const receiver = payment.splits.find(split => split.amount > 0); + if (receiver && receiver.username) { + return receiver.username; + } + + // Fallback: find the user who is not the payer + const otherUser = payment.splits.find(split => split.username !== payment.paidBy); + if (otherUser && otherUser.username) { + return otherUser.username; + } + + return ''; +} \ No newline at end of file diff --git a/src/models/Payment.ts b/src/models/Payment.ts index 44592ad..537d7d3 100644 --- a/src/models/Payment.ts +++ b/src/models/Payment.ts @@ -9,7 +9,7 @@ export interface IPayment { paidBy: string; // username/nickname of the person who paid date: Date; image?: string; // path to uploaded image - category: 'groceries' | 'shopping' | 'travel' | 'restaurant' | 'utilities' | 'fun'; + category: 'groceries' | 'shopping' | 'travel' | 'restaurant' | 'utilities' | 'fun' | 'settlement'; splitMethod: 'equal' | 'full' | 'proportional' | 'personal_equal'; createdBy: string; // username/nickname of the person who created the payment createdAt?: Date; @@ -55,7 +55,7 @@ const PaymentSchema = new mongoose.Schema( category: { type: String, required: true, - enum: ['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun'], + enum: ['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun', 'settlement'], default: 'groceries' }, splitMethod: { diff --git a/src/routes/api/cospend/balance/+server.ts b/src/routes/api/cospend/balance/+server.ts index f17fb21..3a41ed0 100644 --- a/src/routes/api/cospend/balance/+server.ts +++ b/src/routes/api/cospend/balance/+server.ts @@ -61,6 +61,21 @@ export const GET: RequestHandler = async ({ locals, url }) => { .limit(10) .lean(); + // For settlements, fetch the other user's split info + for (const split of recentSplits) { + if (split.paymentId && split.paymentId.category === 'settlement') { + // This is a settlement, find the other user + const otherSplit = await PaymentSplit.findOne({ + paymentId: split.paymentId._id, + username: { $ne: username } + }).lean(); + + if (otherSplit) { + split.otherUser = otherSplit.username; + } + } + } + return json({ netBalance, recentSplits diff --git a/src/routes/api/cospend/payments/+server.ts b/src/routes/api/cospend/payments/+server.ts index e6fca34..a00cbba 100644 --- a/src/routes/api/cospend/payments/+server.ts +++ b/src/routes/api/cospend/payments/+server.ts @@ -52,7 +52,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { throw error(400, 'Invalid split method'); } - if (category && !['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun'].includes(category)) { + if (category && !['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun', 'settlement'].includes(category)) { throw error(400, 'Invalid category'); } diff --git a/src/routes/cospend/+page.svelte b/src/routes/cospend/+page.svelte index 29cc617..3306894 100644 --- a/src/routes/cospend/+page.svelte +++ b/src/routes/cospend/+page.svelte @@ -6,6 +6,7 @@ import EnhancedBalance from '$lib/components/EnhancedBalance.svelte'; import DebtBreakdown from '$lib/components/DebtBreakdown.svelte'; import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories'; + import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements'; export let data; // Used by the layout for session data @@ -57,6 +58,27 @@ // Use pushState for true shallow routing - only updates URL without navigation pushState(`/cospend/payments/view/${paymentId}`, { paymentId }); } + + function getSettlementReceiverFromSplit(split) { + if (!isSettlementPayment(split.paymentId)) { + return ''; + } + + // In a settlement, the receiver is the person who is NOT the payer + // Since we're viewing the current user's activity, the receiver is the current user + // when someone else paid, or the other user when current user paid + + const paidBy = split.paymentId?.paidBy; + const currentUser = data.session?.user?.nickname; + + if (paidBy === currentUser) { + // Current user paid, so receiver is the other user + return split.otherUser || ''; + } else { + // Someone else paid, so current user is the receiver + return currentUser; + } + } @@ -74,6 +96,9 @@
Add Payment View All Payments + {#if balance.netBalance !== 0} + Settle Debts + {/if}
@@ -87,46 +112,79 @@

Recent Activity

{#each balance.recentSplits as split} -
- @@ -216,6 +274,16 @@ background-color: #e8e8e8; } + .btn-settlement { + background: linear-gradient(135deg, #28a745, #20c997); + color: white; + border: none; + } + + .btn-settlement:hover { + background: linear-gradient(135deg, #20c997, #1e7e34); + } + .recent-activity { background: white; padding: 1.5rem; @@ -300,6 +368,79 @@ border-right-color: transparent; } + + /* New Settlement Flow Activity Styles */ + .settlement-flow-activity { + display: block; + text-decoration: none; + color: inherit; + background: linear-gradient(135deg, #f8fff9, #e8f5e8); + border: 2px solid #28a745; + border-radius: 1rem; + padding: 1.5rem; + margin: 0 auto 1rem auto; + max-width: 400px; + transition: all 0.2s ease; + } + + .settlement-flow-activity:hover { + box-shadow: 0 6px 20px rgba(40, 167, 69, 0.2); + transform: translateY(-2px); + } + + .settlement-activity-content { + width: 100%; + } + + .settlement-user-flow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + } + + .settlement-payer, .settlement-receiver { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + flex: 0 0 auto; + } + + .settlement-username { + font-weight: 600; + color: #28a745; + font-size: 1rem; + text-align: center; + } + + .settlement-arrow-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex: 1; + } + + .settlement-amount-large { + font-size: 1.5rem; + font-weight: 700; + color: #28a745; + text-align: center; + } + + .settlement-flow-arrow { + font-size: 1.8rem; + color: #28a745; + font-weight: bold; + } + + .settlement-date { + font-size: 0.9rem; + color: #666; + text-align: center; + } + .activity-header { display: flex; justify-content: space-between; @@ -399,5 +540,28 @@ max-width: 300px; text-align: center; } + + /* Mobile Settlement Flow */ + .settlement-user-flow { + flex-direction: column; + gap: 1rem; + } + + .settlement-payer, .settlement-receiver { + order: 1; + } + + .settlement-arrow-section { + order: 2; + } + + .settlement-flow-arrow { + transform: rotate(90deg); + font-size: 1.5rem; + } + + .settlement-amount-large { + font-size: 1.3rem; + } } \ No newline at end of file diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte index b85cfa5..ebd426d 100644 --- a/src/routes/cospend/payments/+page.svelte +++ b/src/routes/cospend/payments/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import ProfilePicture from '$lib/components/ProfilePicture.svelte'; import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories'; + import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements'; export let data; @@ -135,24 +136,45 @@ {:else}
{#each payments as payment} -
+
-
- -
-
- {getCategoryEmoji(payment.category || 'groceries')} -

{payment.title}

+ {#if isSettlementPayment(payment)} +
+
+ + {payment.paidBy}
-
- {getCategoryName(payment.category || 'groceries')} - {formatDate(payment.date)} - {formatCurrency(payment.amount)} +
+ + Settlement +
+
+ + {getSettlementReceiver(payment)}
-
- {#if payment.image} - Receipt +
+ {formatCurrency(payment.amount)} + {formatDate(payment.date)} +
+ {:else} +
+ +
+
+ {getCategoryEmoji(payment.category || 'groceries')} +

{payment.title}

+
+
+ {getCategoryName(payment.category || 'groceries')} + {formatDate(payment.date)} + {formatCurrency(payment.amount)} +
+
+
+ {#if payment.image} + Receipt + {/if} {/if}
@@ -339,6 +361,71 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } + .settlement-card { + background: linear-gradient(135deg, #f8fff9, #f0f8f0); + border: 2px solid #28a745; + } + + .settlement-card:hover { + box-shadow: 0 4px 16px rgba(40, 167, 69, 0.2); + } + + .settlement-flow { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + } + + .settlement-user-from, .settlement-user-to { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .settlement-user-from .username, + .settlement-user-to .username { + font-weight: 500; + color: #28a745; + } + + .settlement-arrow { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + } + + .settlement-arrow .arrow { + color: #28a745; + font-size: 1.2rem; + font-weight: bold; + } + + .settlement-badge-small { + background: linear-gradient(135deg, #28a745, #20c997); + color: white; + padding: 0.125rem 0.375rem; + border-radius: 0.75rem; + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + .settlement-amount { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + } + + .settlement-amount-text { + font-size: 1.1rem; + font-weight: 600; + color: #28a745; + } + .payment-header { display: flex; justify-content: space-between; diff --git a/src/routes/cospend/settle/+page.server.ts b/src/routes/cospend/settle/+page.server.ts new file mode 100644 index 0000000..61409f0 --- /dev/null +++ b/src/routes/cospend/settle/+page.server.ts @@ -0,0 +1,13 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user) { + throw redirect(302, '/login'); + } + + return { + session: auth + }; +}; \ No newline at end of file diff --git a/src/routes/cospend/settle/+page.svelte b/src/routes/cospend/settle/+page.svelte new file mode 100644 index 0000000..7bde4b4 --- /dev/null +++ b/src/routes/cospend/settle/+page.svelte @@ -0,0 +1,615 @@ + + + + Settle Debts - Cospend + + +
+
+

Settle Debts

+

Record payments to settle outstanding debts between users

+
+ + {#if loading} +
Loading debt information...
+ {:else if error} +
Error: {error}
+ {:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0} +
+

🎉 All Settled!

+

No outstanding debts to settle. Everyone is even!

+ +
+ {:else} +
+ +
+

Available Settlements

+ + {#if debtData.whoOwesMe.length > 0} +
+

Money You're Owed

+ {#each debtData.whoOwesMe as debt} +
selectSettlement('receive', debt.username, debt.netAmount)}> +
+ +
+ {debt.username} + owes you {formatCurrency(debt.netAmount)} +
+
+
+ Receive Payment +
+
+ {/each} +
+ {/if} + + {#if debtData.whoIOwe.length > 0} +
+

Money You Owe

+ {#each debtData.whoIOwe as debt} +
selectSettlement('pay', debt.username, debt.netAmount)}> +
+ +
+ {debt.username} + you owe {formatCurrency(debt.netAmount)} +
+
+
+ Make Payment +
+
+ {/each} +
+ {/if} +
+ + + {#if selectedSettlement} +
+

Settlement Details

+ +
+
+
+ + {selectedSettlement.from} + {#if selectedSettlement.from === data.session?.user?.nickname} + You + {/if} +
+
+
+ + {selectedSettlement.to} + {#if selectedSettlement.to === data.session?.user?.nickname} + You + {/if} +
+
+ +
+ +
+ CHF + +
+ + Maximum: {formatCurrency(selectedSettlement.amount)} + +
+ +
+ Description: {selectedSettlement.description} +
+
+ +
+ + +
+
+ {/if} +
+ + + {/if} +
+ + \ No newline at end of file