Skip to content

Commit 53f3f37

Browse files
feat: implement invoice installment plan builder (#404) (#529)
- Extend SplitMetaSchema with optional installments field and zod sum validation - Add payMilestone flow with invoice/milestone text memo and Freighter signing - Build InstallmentPlanBuilder component for add/remove/reorder UI - Add InvoiceView payer-facing timeline with paid/upcoming/overdue badges - Wire milestone pay buttons to on-chain payment per milestone amount - Surface overdue installments in dashboard filters and badge counts - Persist installments via existing in-memory splitMeta API - Update /invoice/new Options step to include InstallmentPlanBuilder - Add tests for schema validation, dashboard overdue filter, and builder Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 9151f55 commit 53f3f37

12 files changed

Lines changed: 700 additions & 28 deletions

File tree

src/__tests__/dashboardFilters.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ describe("dashboard filter presets", () => {
4949
expect(results.map((i) => i.id)).toEqual(["1"]);
5050
});
5151

52+
test("'overdue' returns pending invoices with splitMeta installments past due date", () => {
53+
const splitMetaMap: Record<string, { installments?: { dueDate: number; status: string }[] }> = {
54+
"1": { installments: [{ dueDate: now - 3600, status: "upcoming" }] },
55+
"2": { installments: [{ dueDate: now + 86400, status: "upcoming" }] },
56+
"3": { installments: [{ dueDate: now - 3600, status: "paid" }] },
57+
};
58+
const results = filterDashboardInvoices(invoices, "overdue", now, splitMetaMap);
59+
expect(results.map((i) => i.id)).toEqual(["1"]);
60+
});
61+
5262
test("'all' returns every invoice", () => {
5363
const results = filterDashboardInvoices(invoices, "all");
5464
expect(results).toHaveLength(5);
@@ -61,10 +71,13 @@ describe("dashboard filter presets", () => {
6171
"refunded",
6272
"expired",
6373
"draft",
74+
"overdue",
6475
]);
6576
expect(DASHBOARD_PRESETS[0].emptyState.toLowerCase()).toContain("active");
6677
expect(DASHBOARD_PRESETS[1].emptyState.toLowerCase()).toContain("funded");
6778
expect(DASHBOARD_PRESETS[2].emptyState.toLowerCase()).toContain("refunded");
6879
expect(DASHBOARD_PRESETS[3].emptyState.toLowerCase()).toContain("expired");
80+
expect(DASHBOARD_PRESETS[4].emptyState.toLowerCase()).toContain("draft");
81+
expect(DASHBOARD_PRESETS[5].emptyState.toLowerCase()).toContain("overdue");
6982
});
7083
});

src/app/invoice/[id]/page.tsx

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ import CountdownTimer from "@/components/CountdownTimer";
3737
import SplitCalculator from "@/components/SplitCalculator";
3838
import InvoiceTagEditor from "@/components/invoice/InvoiceTagEditor";
3939
import type { SplitMeta } from "@/hooks/useSplitCalculator";
40+
import type { InstallmentMilestone } from "@/components/invoice/InvoiceView";
4041
import ActivityFeed from "@/components/ActivityFeed";
4142
import InstallmentTracker from "@/components/InstallmentTracker";
4243
import InstallmentPanel from "@/components/InstallmentPanel";
44+
import InvoiceView from "@/components/invoice/InvoiceView";
4345
import CoCreatorPanel from "@/components/CoCreatorPanel";
4446
import PaymentChannelPanel from "@/components/PaymentChannelPanel";
4547
import DisputeTimeline from "@/components/DisputeTimeline";
@@ -385,7 +387,7 @@ export default function InvoiceDetailPage({ params }: Props) {
385387

386388
if (loading) {
387389
return (
388-
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-16">
390+
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-16 overflow-x-hidden">
389391
<div className="animate-pulse space-y-4">
390392
<div className="h-8 w-48 bg-gray-700 rounded" />
391393
<div className="h-4 w-full bg-gray-700 rounded" />
@@ -739,18 +741,28 @@ export default function InvoiceDetailPage({ params }: Props) {
739741
/>
740742

741743
{/* Installment schedule — only shown to payers with a registered plan */}
742-
{publicKey && (
743-
<>
744-
<InstallmentTracker
745-
invoice={invoice}
746-
publicKey={publicKey}
747-
onPayNow={(amount) => {
748-
setPayAmount(formatAmount(amount));
749-
setShowPayModal(true);
750-
}}
751-
/>
752-
<InstallmentPanel invoiceId={id} publicKey={publicKey} />
753-
</>
744+
{publicKey && loadedSplitMeta?.installments && loadedSplitMeta.installments.length > 0 && (
745+
<InvoiceView
746+
invoice={invoice}
747+
installments={loadedSplitMeta.installments as InstallmentMilestone[]}
748+
publicKey={publicKey}
749+
onPaid={async (milestoneId, txHash) => {
750+
const updated = (loadedSplitMeta.installments || []).map((m) =>
751+
m.id === milestoneId ? { ...m, status: 'paid', txHash } : m
752+
);
753+
const newSplitMeta = { ...loadedSplitMeta, installments: updated } as SplitMeta;
754+
setLoadedSplitMeta(newSplitMeta);
755+
try {
756+
await fetch(`/api/invoices/${id}`, {
757+
method: "PATCH",
758+
headers: { "Content-Type": "application/json" },
759+
body: JSON.stringify({ splitMeta: newSplitMeta }),
760+
});
761+
} catch {
762+
// ignore persistence errors
763+
}
764+
}}
765+
/>
754766
)}
755767

756768
{/* Deadline extension voting — shown to payers on Pending invoices */}

src/app/invoice/new/page.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
calculateSplit,
3434
type SplitMeta,
3535
} from "@/hooks/useSplitCalculator";
36+
import InstallmentPlanBuilder from "@/components/invoice/InstallmentPlanBuilder";
3637

3738
const RecipientForm = dynamic(() => import("@/components/RecipientForm"), { ssr: false });
3839
const TemplateManager = dynamic(() => import("@/components/TemplateManager"), { ssr: false });
@@ -132,6 +133,7 @@ function NewInvoiceForm() {
132133
const [intervalDays, setIntervalDays] = useState<7 | 30>(7);
133134
const [submitting, setSubmitting] = useState(false);
134135
const [splitMeta, setSplitMeta] = useState<SplitMeta | null>(null);
136+
const [installments, setInstallments] = useState<{ id: string; amount: number; dueDate: number; status: string; txHash?: string }[]>([]);
135137
const [tags, setTags] = useState<string[]>([]);
136138
const { allTags, saveTags } = useInvoiceTags();
137139

@@ -178,6 +180,7 @@ function NewInvoiceForm() {
178180
recurring,
179181
intervalDays,
180182
splitMeta,
183+
installments,
181184
};
182185

183186
const { isOffline: draftOffline, discardDraft } = useOfflineDraftAutosave(
@@ -448,6 +451,12 @@ function NewInvoiceForm() {
448451
goToStep(Math.max(step - 1, 0));
449452
};
450453

454+
const payloadForApi = () => {
455+
if (!splitMeta) return null;
456+
if (installments.length === 0) return splitMeta;
457+
return { ...splitMeta, installments };
458+
};
459+
451460
const handleSubmit = async (e: React.FormEvent) => {
452461
e.preventDefault();
453462
if (!validateStep(step)) return;
@@ -485,11 +494,12 @@ function NewInvoiceForm() {
485494
recipients.map((r) => ({ address: r.address, amount: r.amount }))
486495
);
487496
addToast(`Invoice #${invoiceId} created`, "success");
488-
if (splitMeta && splitMeta.recipients.length > 0) {
497+
const apiPayload = payloadForApi();
498+
if (apiPayload && (apiPayload as any).recipients?.length > 0) {
489499
fetch(`/api/invoices/${invoiceId}`, {
490500
method: "PATCH",
491501
headers: { "Content-Type": "application/json" },
492-
body: JSON.stringify({ splitMeta }),
502+
body: JSON.stringify({ splitMeta: apiPayload }),
493503
}).catch(() => null);
494504
}
495505
if (tags.length > 0) {
@@ -514,11 +524,12 @@ function NewInvoiceForm() {
514524
amount: equalSplit ? (perRecipientAmount ?? "0") : r.amount,
515525
}))
516526
);
517-
if (splitMeta && splitMeta.recipients.length > 0) {
527+
const apiPayload = payloadForApi();
528+
if (apiPayload && (apiPayload as any).recipients?.length > 0) {
518529
fetch(`/api/invoices/${invoiceId}`, {
519530
method: "PATCH",
520531
headers: { "Content-Type": "application/json" },
521-
body: JSON.stringify({ splitMeta }),
532+
body: JSON.stringify({ splitMeta: apiPayload }),
522533
}).catch(() => null);
523534
}
524535
if (tags.length > 0) {
@@ -752,7 +763,9 @@ function NewInvoiceForm() {
752763
</div>
753764
);
754765

755-
const renderOptions = () => (
766+
const renderOptions = () => {
767+
const total = recipients.reduce((s, r) => s + parseFloat(r.amount || "0"), 0);
768+
return (
756769
<div className="flex flex-col gap-6">
757770
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Options</h2>
758771

@@ -785,7 +798,7 @@ function NewInvoiceForm() {
785798

786799
{autofilled && !cloneSourceId && (
787800
<p className="text-xs text-indigo-400 bg-indigo-950/50 border border-indigo-800 rounded-lg px-3 py-2">
788-
Autofilled from history — you can override any value.
801+
Autofilled from history — you can override any value below.
789802
</p>
790803
)}
791804

@@ -796,8 +809,16 @@ function NewInvoiceForm() {
796809
</p>
797810
</div>
798811
)}
812+
813+
<InstallmentPlanBuilder
814+
totalAmount={total}
815+
installments={installments}
816+
assetCode={token === (process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "") ? "USDC" : "XLM"}
817+
onChange={setInstallments}
818+
/>
799819
</div>
800-
);
820+
);
821+
};
801822

802823
const renderReview = () => {
803824
const total = recipients.reduce((s, r) => s + parseFloat(r.amount || "0"), 0);

src/components/DashboardClient.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default function DashboardClient() {
7474
// Activity feed panel
7575
const [feedOpen, setFeedOpen] = useState(false);
7676
const { unreadCount } = useActivityFeed();
77+
const [splitMetaMap, setSplitMetaMap] = useState<Record<string, { installments?: { dueDate: number; status: string }[] }>>({});
7778

7879
// ── URL mutation helpers ────────────────────────────────────────────────────
7980

@@ -140,6 +141,58 @@ export default function DashboardClient() {
140141
};
141142
}, [router]);
142143

144+
// Fetch invoices progressively
145+
useEffect(() => {
146+
if (!publicKey) return;
147+
const fetchInvoices = async () => {
148+
setLoading(true);
149+
const results: Invoice[] = [];
150+
for (let id = 1; id <= 50; id++) {
151+
try {
152+
const inv = await splitClient.getInvoice(String(id));
153+
const mine =
154+
inv.creator === publicKey ||
155+
inv.recipients.some((r) => r.address === publicKey);
156+
if (mine) {
157+
results.push(inv);
158+
setInvoices([...results]);
159+
}
160+
} catch {
161+
break;
162+
}
163+
}
164+
setLoading(false);
165+
};
166+
fetchInvoices().catch((e) => { setError(String(e)); setLoading(false); });
167+
}, [publicKey]);
168+
169+
// Fetch splitMeta for overdue detection
170+
useEffect(() => {
171+
if (!publicKey || invoices.length === 0) return;
172+
let cancelled = false;
173+
(async () => {
174+
const map: Record<string, { installments?: { dueDate: number; status: string }[] }> = {};
175+
await Promise.all(
176+
invoices.map(async (inv) => {
177+
try {
178+
const res = await fetch(`/api/invoices/${inv.id}`);
179+
if (res.ok) {
180+
const json = await res.json();
181+
if (json.splitMeta?.installments) {
182+
map[inv.id] = json.splitMeta;
183+
}
184+
}
185+
} catch {
186+
// ignore
187+
}
188+
})
189+
);
190+
if (!cancelled) setSplitMetaMap(map);
191+
})();
192+
return () => { cancelled = true; };
193+
}, [publicKey, invoices]);
194+
195+
// Numeric search debounce
143196
// ── Numeric search debounce ─────────────────────────────────────────────────
144197
useEffect(() => {
145198
const trimmed = searchValue.trim();
@@ -165,7 +218,7 @@ export default function DashboardClient() {
165218

166219
// ── Derived data ────────────────────────────────────────────────────────────
167220

168-
const presetCounts = useMemo(() => getDashboardPresetCounts(invoices), [invoices]);
221+
const presetCounts = useMemo(() => getDashboardPresetCounts(invoices, Math.floor(Date.now() / 1000), splitMetaMap), [invoices, splitMetaMap]);
169222

170223
const visibleInvoices = useMemo(() => {
171224
// 1. status filter (multi-select chips); if none selected show all
@@ -174,7 +227,7 @@ export default function DashboardClient() {
174227
? invoices
175228
: invoices.filter((inv) =>
176229
statuses.some((s) =>
177-
filterDashboardInvoices([inv], s).length > 0,
230+
filterDashboardInvoices([inv], s, Math.floor(Date.now() / 1000), splitMetaMap).length > 0,
178231
),
179232
);
180233
// 2. date range
@@ -186,6 +239,7 @@ export default function DashboardClient() {
186239
// 4. sort
187240
result = sortInvoices(result, sort);
188241
return result;
242+
}, [invoices, statuses, dateFrom, dateTo, sort, splitMetaMap]);
189243
}, [invoices, statuses, dateFrom, dateTo, sort, tag, tagsByInvoice]);
190244

191245
const { totalActive, totalValueLocked, totalReleased } = useMemo(() => {

0 commit comments

Comments
 (0)