Skip to content

Commit 60f607e

Browse files
committed
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
1 parent 3488554 commit 60f607e

5 files changed

Lines changed: 692 additions & 82 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
import { splitClient } from "@/lib/stellar";
6+
import { assertCsrf } from "@/lib/middleware/csrfMiddleware";
7+
8+
/**
9+
* In-memory store for manually-marked installment payments.
10+
* Keyed by `${invoiceId}:${index}`.
11+
*
12+
* In production this would be persisted in a database.
13+
*/
14+
const markedPaidStore = new Map<string, { paidAt: string; markedBy: string }>();
15+
16+
/**
17+
* POST /api/invoices/[id]/installments/[index]/mark-paid
18+
*
19+
* Marks a specific installment index as paid for the given invoice.
20+
* The caller must be the invoice creator or a recipient, identified by the
21+
* `x-wallet-public-key` request header.
22+
*
23+
* #615: Persists the off-chain "mark as paid" override so InstallmentTracker
24+
* can reflect the updated status.
25+
*/
26+
export async function POST(
27+
request: NextRequest,
28+
{ params }: { params: { id: string; index: string } }
29+
) {
30+
const csrfError = await assertCsrf(request);
31+
if (csrfError) return csrfError;
32+
33+
const invoiceId = params.id;
34+
const indexStr = params.index;
35+
36+
// Validate index
37+
const index = parseInt(indexStr, 10);
38+
if (isNaN(index) || index < 0) {
39+
return NextResponse.json(
40+
{ error: "Invalid installment index" },
41+
{ status: 400 }
42+
);
43+
}
44+
45+
// Require wallet public key for authorization
46+
const walletPublicKey = request.headers.get("x-wallet-public-key");
47+
if (!walletPublicKey) {
48+
return NextResponse.json(
49+
{ error: "Missing x-wallet-public-key header" },
50+
{ status: 403 }
51+
);
52+
}
53+
54+
// Verify the caller is the invoice creator or a recipient
55+
let invoice;
56+
try {
57+
invoice = await splitClient.getInvoice(invoiceId);
58+
} catch {
59+
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
60+
}
61+
62+
const isCreator = invoice.creator === walletPublicKey;
63+
const isRecipient = invoice.recipients.some(
64+
(r: { address: string }) => r.address === walletPublicKey
65+
);
66+
67+
if (!isCreator && !isRecipient) {
68+
return NextResponse.json(
69+
{ error: "Not authorised to update this invoice" },
70+
{ status: 403 }
71+
);
72+
}
73+
74+
const storeKey = `${invoiceId}:${index}`;
75+
76+
if (markedPaidStore.has(storeKey)) {
77+
return NextResponse.json(
78+
{ error: "Installment already marked as paid" },
79+
{ status: 409 }
80+
);
81+
}
82+
83+
const record = { paidAt: new Date().toISOString(), markedBy: walletPublicKey };
84+
markedPaidStore.set(storeKey, record);
85+
86+
return NextResponse.json(
87+
{
88+
success: true,
89+
invoiceId,
90+
index,
91+
...record,
92+
},
93+
{ status: 200 }
94+
);
95+
}
96+
97+
/**
98+
* GET /api/invoices/[id]/installments/[index]/mark-paid
99+
*
100+
* Returns the mark-paid record for this installment if it exists.
101+
*/
102+
export async function GET(
103+
_request: NextRequest,
104+
{ params }: { params: { id: string; index: string } }
105+
) {
106+
const storeKey = `${params.id}:${params.index}`;
107+
const record = markedPaidStore.get(storeKey);
108+
109+
if (!record) {
110+
return NextResponse.json({ paid: false }, { status: 200 });
111+
}
112+
113+
return NextResponse.json({ paid: true, ...record }, { status: 200 });
114+
}

src/components/EscrowPanel.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,66 @@ import { useEffect, useRef, useState } from "react";
44
import { formatAmount } from "@stellar-split/sdk";
55
import type { Invoice } from "@stellar-split/sdk";
66

7+
// --- #613: live countdown timer helpers ---
8+
9+
function calcReleaseTimeLeft(deadline: number): number {
10+
return Math.max(0, deadline - Math.floor(Date.now() / 1000));
11+
}
12+
13+
interface CountdownParts {
14+
days: number;
15+
hours: number;
16+
minutes: number;
17+
}
18+
19+
function splitSeconds(totalSeconds: number): CountdownParts {
20+
const days = Math.floor(totalSeconds / 86400);
21+
const hours = Math.floor((totalSeconds % 86400) / 3600);
22+
const minutes = Math.floor((totalSeconds % 3600) / 60);
23+
return { days, hours, minutes };
24+
}
25+
26+
/**
27+
* ReleaseCountdown — shows days/hours/minutes remaining until escrow release.
28+
* Updates every minute. Displays "Release available" once the deadline has passed.
29+
*/
30+
function ReleaseCountdown({ deadline }: { deadline: number }) {
31+
const [timeLeft, setTimeLeft] = useState(() => calcReleaseTimeLeft(deadline));
32+
33+
useEffect(() => {
34+
if (timeLeft === 0) return;
35+
36+
const id = setInterval(() => {
37+
const remaining = calcReleaseTimeLeft(deadline);
38+
setTimeLeft(remaining);
39+
if (remaining === 0) clearInterval(id);
40+
}, 60_000);
41+
42+
return () => clearInterval(id);
43+
}, [deadline, timeLeft]);
44+
45+
if (timeLeft === 0) {
46+
return (
47+
<span className="text-xs font-semibold text-green-400" aria-live="polite">
48+
Release available
49+
</span>
50+
);
51+
}
52+
53+
const { days, hours, minutes } = splitSeconds(timeLeft);
54+
55+
return (
56+
<span
57+
className="text-xs font-mono font-semibold text-yellow-300 tabular-nums"
58+
aria-live="polite"
59+
title="Time remaining until escrow release"
60+
>
61+
{days}d {hours}h {minutes}m remaining
62+
</span>
63+
);
64+
}
65+
// --- end #613 ---
66+
767
interface Props {
868
invoice: Invoice;
969
total: bigint;
@@ -137,8 +197,12 @@ export default function EscrowPanel({ invoice, total }: Props) {
137197
<Check ok={!deadlinePassed} />
138198
<span className="text-gray-300">Deadline not passed</span>
139199
{invoice.deadline > 0 && (
140-
<span className="ml-auto text-xs text-gray-500">
141-
{new Date(invoice.deadline * 1000).toLocaleDateString()}
200+
<span className="ml-auto flex flex-col items-end gap-0.5">
201+
<span className="text-xs text-gray-500">
202+
{new Date(invoice.deadline * 1000).toLocaleDateString()}
203+
</span>
204+
{/* #613: live countdown timer */}
205+
<ReleaseCountdown deadline={invoice.deadline} />
142206
</span>
143207
)}
144208
</li>

src/components/InstallmentPanel.tsx

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import { splitClient } from "@/lib/stellar";
55
import { formatAmount } from "@stellar-split/sdk";
66

@@ -13,25 +13,100 @@ interface Installment {
1313
interface Props {
1414
invoiceId: string;
1515
publicKey: string;
16+
/** #614: when total changes, amounts are recalculated proportionally */
17+
total?: bigint;
18+
}
19+
20+
/**
21+
* #614 — recalculate installment amounts proportionally when `total` changes.
22+
*
23+
* The proportion of each installment is derived from the original fetched plan.
24+
* Rounding remainders are applied to the last installment.
25+
* A brief "Updated" badge flashes after each recalculation.
26+
*/
27+
function recalcAmounts(original: Installment[], newTotal: bigint): Installment[] {
28+
if (original.length === 0) return original;
29+
30+
const originalTotal = original.reduce((s, i) => s + i.amount, 0n);
31+
if (originalTotal === 0n) return original;
32+
33+
// Calculate proportional amounts; keep track of distributed sum to fix rounding
34+
const recalculated: Installment[] = original.map((inst) => ({
35+
...inst,
36+
amount: (inst.amount * newTotal) / originalTotal,
37+
}));
38+
39+
// Assign rounding remainder to the last installment
40+
const distributed = recalculated.reduce((s, i) => s + i.amount, 0n);
41+
const remainder = newTotal - distributed;
42+
if (remainder !== 0n) {
43+
const last = recalculated[recalculated.length - 1];
44+
recalculated[recalculated.length - 1] = {
45+
...last,
46+
amount: last.amount + remainder,
47+
};
48+
}
49+
50+
return recalculated;
1651
}
1752

1853
/**
1954
* InstallmentPanel — shows the payer's installment schedule for an invoice.
2055
* Highlights the next due installment; marks past ones as paid if payment exists.
2156
*/
22-
export default function InstallmentPanel({ invoiceId, publicKey }: Props) {
57+
export default function InstallmentPanel({ invoiceId, publicKey, total }: Props) {
58+
const [baseInstallments, setBaseInstallments] = useState<Installment[] | null>(null);
2359
const [installments, setInstallments] = useState<Installment[] | null>(null);
2460
const [loading, setLoading] = useState(true);
61+
// #614: flash badge state
62+
const [showUpdated, setShowUpdated] = useState(false);
63+
const prevTotal = useRef<bigint | undefined>(undefined);
2564

65+
// Fetch plan once on mount
2666
useEffect(() => {
2767
/* eslint-disable-next-line */
2868
(splitClient as any)
2969
.getInstallmentPlan(invoiceId, publicKey)
30-
.then((plan: Installment[] | null) => setInstallments(plan ?? []))
31-
.catch(() => setInstallments([]))
70+
.then((plan: Installment[] | null) => {
71+
const resolved = plan ?? [];
72+
setBaseInstallments(resolved);
73+
// Apply total immediately if provided
74+
if (total !== undefined && resolved.length > 0) {
75+
setInstallments(recalcAmounts(resolved, total));
76+
} else {
77+
setInstallments(resolved);
78+
}
79+
prevTotal.current = total;
80+
})
81+
.catch(() => {
82+
setBaseInstallments([]);
83+
setInstallments([]);
84+
})
3285
.finally(() => setLoading(false));
86+
// eslint-disable-next-line react-hooks/exhaustive-deps
3387
}, [invoiceId, publicKey]);
3488

89+
// #614: recalculate whenever total prop changes after initial load
90+
useEffect(() => {
91+
if (
92+
baseInstallments === null ||
93+
baseInstallments.length === 0 ||
94+
total === undefined
95+
)
96+
return;
97+
98+
// Skip the very first assignment (handled in the fetch effect)
99+
if (prevTotal.current === total) return;
100+
101+
prevTotal.current = total;
102+
setInstallments(recalcAmounts(baseInstallments, total));
103+
104+
// Flash "Updated" badge for 1.5 s
105+
setShowUpdated(true);
106+
const t = setTimeout(() => setShowUpdated(false), 1500);
107+
return () => clearTimeout(t);
108+
}, [total, baseInstallments]);
109+
35110
if (loading) return null;
36111

37112
if (!installments || installments.length === 0) {
@@ -48,7 +123,15 @@ export default function InstallmentPanel({ invoiceId, publicKey }: Props) {
48123

49124
return (
50125
<section className="mb-8">
51-
<h2 className="text-lg font-semibold mb-3">Installment Schedule</h2>
126+
<div className="flex items-center gap-3 mb-3">
127+
<h2 className="text-lg font-semibold">Installment Schedule</h2>
128+
{/* #614: visual indicator after recalculation */}
129+
{showUpdated && (
130+
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-indigo-700 text-indigo-100 animate-pulse">
131+
Updated
132+
</span>
133+
)}
134+
</div>
52135
<ol className="flex flex-col gap-2">
53136
{installments.map((inst, i) => {
54137
const isNext = i === nextDueIndex;

0 commit comments

Comments
 (0)