Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, { paidAt: string; markedBy: string }>();

/**
* 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 });
}
68 changes: 66 additions & 2 deletions src/components/EscrowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="text-xs font-semibold text-green-400" aria-live="polite">
Release available
</span>
);
}

const { days, hours, minutes } = splitSeconds(timeLeft);

return (
<span
className="text-xs font-mono font-semibold text-yellow-300 tabular-nums"
aria-live="polite"
title="Time remaining until escrow release"
>
{days}d {hours}h {minutes}m remaining
</span>
);
}
// --- end #613 ---

interface Props {
invoice: Invoice;
total: bigint;
Expand Down Expand Up @@ -137,8 +197,12 @@ export default function EscrowPanel({ invoice, total }: Props) {
<Check ok={!deadlinePassed} />
<span className="text-gray-300">Deadline not passed</span>
{invoice.deadline > 0 && (
<span className="ml-auto text-xs text-gray-500">
{new Date(invoice.deadline * 1000).toLocaleDateString()}
<span className="ml-auto flex flex-col items-end gap-0.5">
<span className="text-xs text-gray-500">
{new Date(invoice.deadline * 1000).toLocaleDateString()}
</span>
{/* #613: live countdown timer */}
<ReleaseCountdown deadline={invoice.deadline} />
</span>
)}
</li>
Expand Down
93 changes: 88 additions & 5 deletions src/components/InstallmentPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<Installment[] | null>(null);
const [installments, setInstallments] = useState<Installment[] | null>(null);
const [loading, setLoading] = useState(true);
// #614: flash badge state
const [showUpdated, setShowUpdated] = useState(false);
const prevTotal = useRef<bigint | undefined>(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) {
Expand All @@ -48,7 +123,15 @@ export default function InstallmentPanel({ invoiceId, publicKey }: Props) {

return (
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">Installment Schedule</h2>
<div className="flex items-center gap-3 mb-3">
<h2 className="text-lg font-semibold">Installment Schedule</h2>
{/* #614: visual indicator after recalculation */}
{showUpdated && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-indigo-700 text-indigo-100 animate-pulse">
Updated
</span>
)}
</div>
<ol className="flex flex-col gap-2">
{installments.map((inst, i) => {
const isNext = i === nextDueIndex;
Expand Down
Loading