From 60f607e5bc802610daf7c04468d0f55ab9eeb3bc Mon Sep 17 00:00:00 2001 From: solarix-x Date: Fri, 28 Aug 2026 14:05:09 +0000 Subject: [PATCH] feat: escrow countdown, installment recalc, mark-as-paid, recurring calendar - #613 EscrowPanel: add live days/hours/minutes countdown timer that updates every minute; shows 'Release available' once deadline passes - #614 InstallmentPanel: accept optional `total` prop and proportionally recalculate installment amounts on change; rounding remainder assigned to last installment; flashes 'Updated' badge after each recalculation - #615 InstallmentTracker: add 'Mark as Paid' button on each unpaid row; confirmation dialog shown before persisting; optimistic local update shows green Paid badge; backed by new POST/GET API route at /api/invoices/[id]/installments/[index]/mark-paid - #616 RecurringWizard: add mini calendar preview (MiniCalendar + ScheduleCalendarPreview) showing next 6 payment dates highlighted; re-renders on frequency, end-date, or occurrences changes; visible on all 3 wizard steps --- .../installments/[index]/mark-paid/route.ts | 114 +++++++ src/components/EscrowPanel.tsx | 68 +++- src/components/InstallmentPanel.tsx | 93 +++++- src/components/InstallmentTracker.tsx | 303 +++++++++++++----- src/components/RecurringWizard.tsx | 196 ++++++++++- 5 files changed, 692 insertions(+), 82 deletions(-) create mode 100644 src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts diff --git a/src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts b/src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts new file mode 100644 index 0000000..f4703e7 --- /dev/null +++ b/src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +import { splitClient } from "@/lib/stellar"; +import { assertCsrf } from "@/lib/middleware/csrfMiddleware"; + +/** + * In-memory store for manually-marked installment payments. + * Keyed by `${invoiceId}:${index}`. + * + * In production this would be persisted in a database. + */ +const markedPaidStore = new Map(); + +/** + * POST /api/invoices/[id]/installments/[index]/mark-paid + * + * Marks a specific installment index as paid for the given invoice. + * The caller must be the invoice creator or a recipient, identified by the + * `x-wallet-public-key` request header. + * + * #615: Persists the off-chain "mark as paid" override so InstallmentTracker + * can reflect the updated status. + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string; index: string } } +) { + const csrfError = await assertCsrf(request); + if (csrfError) return csrfError; + + const invoiceId = params.id; + const indexStr = params.index; + + // Validate index + const index = parseInt(indexStr, 10); + if (isNaN(index) || index < 0) { + return NextResponse.json( + { error: "Invalid installment index" }, + { status: 400 } + ); + } + + // Require wallet public key for authorization + const walletPublicKey = request.headers.get("x-wallet-public-key"); + if (!walletPublicKey) { + return NextResponse.json( + { error: "Missing x-wallet-public-key header" }, + { status: 403 } + ); + } + + // Verify the caller is the invoice creator or a recipient + let invoice; + try { + invoice = await splitClient.getInvoice(invoiceId); + } catch { + return NextResponse.json({ error: "Invoice not found" }, { status: 404 }); + } + + const isCreator = invoice.creator === walletPublicKey; + const isRecipient = invoice.recipients.some( + (r: { address: string }) => r.address === walletPublicKey + ); + + if (!isCreator && !isRecipient) { + return NextResponse.json( + { error: "Not authorised to update this invoice" }, + { status: 403 } + ); + } + + const storeKey = `${invoiceId}:${index}`; + + if (markedPaidStore.has(storeKey)) { + return NextResponse.json( + { error: "Installment already marked as paid" }, + { status: 409 } + ); + } + + const record = { paidAt: new Date().toISOString(), markedBy: walletPublicKey }; + markedPaidStore.set(storeKey, record); + + return NextResponse.json( + { + success: true, + invoiceId, + index, + ...record, + }, + { status: 200 } + ); +} + +/** + * GET /api/invoices/[id]/installments/[index]/mark-paid + * + * Returns the mark-paid record for this installment if it exists. + */ +export async function GET( + _request: NextRequest, + { params }: { params: { id: string; index: string } } +) { + const storeKey = `${params.id}:${params.index}`; + const record = markedPaidStore.get(storeKey); + + if (!record) { + return NextResponse.json({ paid: false }, { status: 200 }); + } + + return NextResponse.json({ paid: true, ...record }, { status: 200 }); +} diff --git a/src/components/EscrowPanel.tsx b/src/components/EscrowPanel.tsx index a86904e..baf05ad 100644 --- a/src/components/EscrowPanel.tsx +++ b/src/components/EscrowPanel.tsx @@ -4,6 +4,66 @@ import { useEffect, useRef, useState } from "react"; import { formatAmount } from "@stellar-split/sdk"; import type { Invoice } from "@stellar-split/sdk"; +// --- #613: live countdown timer helpers --- + +function calcReleaseTimeLeft(deadline: number): number { + return Math.max(0, deadline - Math.floor(Date.now() / 1000)); +} + +interface CountdownParts { + days: number; + hours: number; + minutes: number; +} + +function splitSeconds(totalSeconds: number): CountdownParts { + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + return { days, hours, minutes }; +} + +/** + * ReleaseCountdown — shows days/hours/minutes remaining until escrow release. + * Updates every minute. Displays "Release available" once the deadline has passed. + */ +function ReleaseCountdown({ deadline }: { deadline: number }) { + const [timeLeft, setTimeLeft] = useState(() => calcReleaseTimeLeft(deadline)); + + useEffect(() => { + if (timeLeft === 0) return; + + const id = setInterval(() => { + const remaining = calcReleaseTimeLeft(deadline); + setTimeLeft(remaining); + if (remaining === 0) clearInterval(id); + }, 60_000); + + return () => clearInterval(id); + }, [deadline, timeLeft]); + + if (timeLeft === 0) { + return ( + + Release available + + ); + } + + const { days, hours, minutes } = splitSeconds(timeLeft); + + return ( + + {days}d {hours}h {minutes}m remaining + + ); +} +// --- end #613 --- + interface Props { invoice: Invoice; total: bigint; @@ -137,8 +197,12 @@ export default function EscrowPanel({ invoice, total }: Props) { Deadline not passed {invoice.deadline > 0 && ( - - {new Date(invoice.deadline * 1000).toLocaleDateString()} + + + {new Date(invoice.deadline * 1000).toLocaleDateString()} + + {/* #613: live countdown timer */} + )} diff --git a/src/components/InstallmentPanel.tsx b/src/components/InstallmentPanel.tsx index 1251644..d069c60 100644 --- a/src/components/InstallmentPanel.tsx +++ b/src/components/InstallmentPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { splitClient } from "@/lib/stellar"; import { formatAmount } from "@stellar-split/sdk"; @@ -13,25 +13,100 @@ interface Installment { interface Props { invoiceId: string; publicKey: string; + /** #614: when total changes, amounts are recalculated proportionally */ + total?: bigint; +} + +/** + * #614 — recalculate installment amounts proportionally when `total` changes. + * + * The proportion of each installment is derived from the original fetched plan. + * Rounding remainders are applied to the last installment. + * A brief "Updated" badge flashes after each recalculation. + */ +function recalcAmounts(original: Installment[], newTotal: bigint): Installment[] { + if (original.length === 0) return original; + + const originalTotal = original.reduce((s, i) => s + i.amount, 0n); + if (originalTotal === 0n) return original; + + // Calculate proportional amounts; keep track of distributed sum to fix rounding + const recalculated: Installment[] = original.map((inst) => ({ + ...inst, + amount: (inst.amount * newTotal) / originalTotal, + })); + + // Assign rounding remainder to the last installment + const distributed = recalculated.reduce((s, i) => s + i.amount, 0n); + const remainder = newTotal - distributed; + if (remainder !== 0n) { + const last = recalculated[recalculated.length - 1]; + recalculated[recalculated.length - 1] = { + ...last, + amount: last.amount + remainder, + }; + } + + return recalculated; } /** * InstallmentPanel — shows the payer's installment schedule for an invoice. * Highlights the next due installment; marks past ones as paid if payment exists. */ -export default function InstallmentPanel({ invoiceId, publicKey }: Props) { +export default function InstallmentPanel({ invoiceId, publicKey, total }: Props) { + const [baseInstallments, setBaseInstallments] = useState(null); const [installments, setInstallments] = useState(null); const [loading, setLoading] = useState(true); + // #614: flash badge state + const [showUpdated, setShowUpdated] = useState(false); + const prevTotal = useRef(undefined); + // Fetch plan once on mount useEffect(() => { /* eslint-disable-next-line */ (splitClient as any) .getInstallmentPlan(invoiceId, publicKey) - .then((plan: Installment[] | null) => setInstallments(plan ?? [])) - .catch(() => setInstallments([])) + .then((plan: Installment[] | null) => { + const resolved = plan ?? []; + setBaseInstallments(resolved); + // Apply total immediately if provided + if (total !== undefined && resolved.length > 0) { + setInstallments(recalcAmounts(resolved, total)); + } else { + setInstallments(resolved); + } + prevTotal.current = total; + }) + .catch(() => { + setBaseInstallments([]); + setInstallments([]); + }) .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [invoiceId, publicKey]); + // #614: recalculate whenever total prop changes after initial load + useEffect(() => { + if ( + baseInstallments === null || + baseInstallments.length === 0 || + total === undefined + ) + return; + + // Skip the very first assignment (handled in the fetch effect) + if (prevTotal.current === total) return; + + prevTotal.current = total; + setInstallments(recalcAmounts(baseInstallments, total)); + + // Flash "Updated" badge for 1.5 s + setShowUpdated(true); + const t = setTimeout(() => setShowUpdated(false), 1500); + return () => clearTimeout(t); + }, [total, baseInstallments]); + if (loading) return null; if (!installments || installments.length === 0) { @@ -48,7 +123,15 @@ export default function InstallmentPanel({ invoiceId, publicKey }: Props) { return (
-

Installment Schedule

+
+

Installment Schedule

+ {/* #614: visual indicator after recalculation */} + {showUpdated && ( + + Updated + + )} +
    {installments.map((inst, i) => { const isNext = i === nextDueIndex; diff --git a/src/components/InstallmentTracker.tsx b/src/components/InstallmentTracker.tsx index a39a180..d521a05 100644 --- a/src/components/InstallmentTracker.tsx +++ b/src/components/InstallmentTracker.tsx @@ -18,14 +18,98 @@ interface Props { onPayNow?: (amount: bigint) => void; } +/** + * #615: MarkAsPaidDialog — confirmation dialog shown before marking an installment paid. + */ +interface MarkAsPaidDialogProps { + index: number; + amount: bigint; + dueDate: number; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +} + +function MarkAsPaidDialog({ + index, + amount, + dueDate, + onConfirm, + onCancel, + loading, +}: MarkAsPaidDialogProps) { + return ( +
    +
    +

    + Mark Installment #{index + 1} as Paid? +

    +

    + Due:{' '} + + {new Date(dueDate * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + +

    +

    + Amount:{' '} + + {formatAmount(amount)} USDC + +

    +

    + This will record the installment as paid via the invoices API. This + action reflects an off-chain payment and cannot be undone automatically. +

    +
    + + +
    +
    +
    + ); +} + /** * InstallmentTracker — shows payer's installment progress with overall completion bar. * Highlights next due installment and provides "Pay Now" button. + * + * #615: Each unpaid installment row has a "Mark as Paid" button. + * Clicking it opens a confirmation dialog before persisting the update. */ export default function InstallmentTracker({ invoice, publicKey, onPayNow }: Props) { const [installments, setInstallments] = useState(null); const [loading, setLoading] = useState(true); + // #615: dialog state + const [pendingIndex, setPendingIndex] = useState(null); + const [saving, setSaving] = useState(false); + useEffect(() => { (splitClient as any) .getInstallmentPlan(invoice.id, publicKey) @@ -44,87 +128,158 @@ export default function InstallmentTracker({ invoice, publicKey, onPayNow }: Pro const nextDueIndex = installments.findIndex((inst) => !inst.paid && inst.dueDate >= now); const nextInstallment = nextDueIndex >= 0 ? installments[nextDueIndex] : null; + // #615: persist mark-as-paid via API then optimistically update local state + const handleConfirmMarkPaid = async () => { + if (pendingIndex === null) return; + setSaving(true); + try { + await fetch( + `/api/invoices/${encodeURIComponent(invoice.id)}/installments/${pendingIndex}/mark-paid`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-wallet-public-key': publicKey, + }, + } + ); + // Optimistic update: mark the installment as paid in local state + setInstallments((prev) => + prev + ? prev.map((inst, i) => + i === pendingIndex ? { ...inst, paid: true } : inst + ) + : prev + ); + } catch { + // Silently fail — user can retry; do not leave dialog open + } finally { + setSaving(false); + setPendingIndex(null); + } + }; + return ( -
    -

    Installment Progress

    + <> + {/* #615: confirmation dialog (rendered outside card flow for z-index) */} + {pendingIndex !== null && installments[pendingIndex] && ( + setPendingIndex(null)} + loading={saving} + /> + )} -
    -
    - - {paidCount} of {totalCount} paid - - {completionPct}% +
    +

    Installment Progress

    + +
    +
    + + {paidCount} of {totalCount} paid + + {completionPct}% +
    + sum + i.amount, 0n)} + />
    - sum + i.amount, 0n)} - /> -
    -
    - {installments.map((inst, i) => { - const isNext = i === nextDueIndex; - const isPaid = inst.paid; - const isPast = !isPaid && inst.dueDate < now; - - return ( -
    -
    -
    - {isPaid ? ( - - ) : isNext ? ( - - ) : isPast ? ( - ! - ) : ( - - )} +
    + {installments.map((inst, i) => { + const isNext = i === nextDueIndex; + const isPaid = inst.paid; + const isPast = !isPaid && inst.dueDate < now; + + return ( +
    +
    +
    + {isPaid ? ( + + ) : isNext ? ( + + ) : isPast ? ( + ! + ) : ( + + )} +
    +
    +

    + {new Date(inst.dueDate * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

    +

    + {isPaid ? ( + // #615: "Paid" badge after marking + + ✓ Paid + + ) : isNext ? ( + 'Next due' + ) : isPast ? ( + 'Overdue' + ) : ( + 'Upcoming' + )} +

    +
    -
    -

    - {new Date(inst.dueDate * 1000).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} -

    -

    - {isPaid ? 'Paid' : isNext ? 'Next due' : isPast ? 'Overdue' : 'Upcoming'} -

    + +
    + + {formatAmount(inst.amount)} USDC + + + {/* #615: Mark as Paid button — only shown for unpaid installments */} + {!isPaid && ( + + )}
    - - {formatAmount(inst.amount)} USDC - -
    - ); - })} -
    + ); + })} +
    - {nextInstallment && onPayNow && ( - - )} -
    + {nextInstallment && onPayNow && ( + + )} +
+ ); } diff --git a/src/components/RecurringWizard.tsx b/src/components/RecurringWizard.tsx index 380ddfa..15c635d 100644 --- a/src/components/RecurringWizard.tsx +++ b/src/components/RecurringWizard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; interface RecurringConfig { enabled: boolean; @@ -13,6 +13,176 @@ interface Props { onConfirm: (config: RecurringConfig) => void; } +// --- #616: mini calendar helpers --- + +const DAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +/** + * Compute the next `count` payment dates starting from `startDate`, spaced by + * `intervalDays` days, stopping at `endDate` if provided. + */ +function computeSchedule( + intervalDays: number, + startDate: Date, + endDate: Date | null, + count: number +): Date[] { + const dates: Date[] = []; + let current = new Date(startDate); + while (dates.length < count) { + current = new Date(current.getTime() + intervalDays * 24 * 60 * 60 * 1000); + if (endDate && current > endDate) break; + dates.push(new Date(current)); + } + return dates; +} + +/** + * MiniCalendar — renders a single month calendar with highlighted payment dates. + */ +interface MiniCalendarProps { + year: number; + month: number; // 0-indexed + highlightedDays: Set; +} + +function MiniCalendar({ year, month, highlightedDays }: MiniCalendarProps) { + const monthName = new Date(year, month).toLocaleString("default", { + month: "long", + year: "numeric", + }); + const daysInMonth = getDaysInMonth(year, month); + const firstDayOfWeek = new Date(year, month, 1).getDay(); + + const cells: (number | null)[] = []; + for (let i = 0; i < firstDayOfWeek; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + return ( +
+

{monthName}

+
+ {DAY_LABELS.map((label) => ( +
+ {label} +
+ ))} + {cells.map((day, i) => { + if (day === null) return
; + const isHighlighted = highlightedDays.has(day); + return ( +
+ {day} +
+ ); + })} +
+
+ ); +} + +/** + * ScheduleCalendarPreview — renders a grid of mini calendars for the next 6 + * scheduled payment dates. + * + * Re-renders automatically when intervalDays, startDate, or endDate change. + */ +interface ScheduleCalendarPreviewProps { + intervalDays: 7 | 30; + endDate: string; // ISO date string or "" + useMaxOccurrences: boolean; + maxOccurrences: string; +} + +function ScheduleCalendarPreview({ + intervalDays, + endDate, + useMaxOccurrences, + maxOccurrences, +}: ScheduleCalendarPreviewProps) { + const scheduleDates = useMemo(() => { + const startDate = new Date(); + const resolvedEndDate = + !useMaxOccurrences && endDate ? new Date(endDate) : null; + const limit = useMaxOccurrences + ? Math.min(parseInt(maxOccurrences) || 6, 6) + : 6; + return computeSchedule(intervalDays, startDate, resolvedEndDate, limit); + }, [intervalDays, endDate, useMaxOccurrences, maxOccurrences]); + + // Group dates by year-month + const monthGroups = useMemo(() => { + const groups: Map }> = + new Map(); + for (const date of scheduleDates) { + const key = `${date.getFullYear()}-${date.getMonth()}`; + if (!groups.has(key)) { + groups.set(key, { + year: date.getFullYear(), + month: date.getMonth(), + days: new Set(), + }); + } + groups.get(key)!.days.add(date.getDate()); + } + return Array.from(groups.values()); + }, [scheduleDates]); + + return ( +
+

+ Next {scheduleDates.length} payment date{scheduleDates.length !== 1 ? "s" : ""} +

+ + {scheduleDates.length === 0 ? ( +

+ No upcoming dates — adjust your end date or occurrences. +

+ ) : ( + <> +
+ {monthGroups.map((group) => ( + + ))} +
+
+ + Scheduled payment date +
+ + )} +
+ ); +} +// --- end #616 --- + export default function RecurringWizard({ onConfirm }: Props) { const [step, setStep] = useState(1); const [intervalDays, setIntervalDays] = useState<7 | 30>(7); @@ -80,6 +250,14 @@ export default function RecurringWizard({ onConfirm }: Props) { ))}
+ + {/* #616: mini calendar preview — visible from step 1 onwards */} + )} @@ -127,6 +305,14 @@ export default function RecurringWizard({ onConfirm }: Props) { /> )} + + {/* #616: mini calendar updates as user changes end date / occurrences */} + )} @@ -146,6 +332,14 @@ export default function RecurringWizard({ onConfirm }: Props) { ))} + + {/* #616: mini calendar on confirmation step too */} + )}