Skip to content

Commit d1b084b

Browse files
#399 Invoice Payment Split Calculator with Tax Deduction (#523)
1 parent 5b5eff6 commit d1b084b

10 files changed

Lines changed: 1241 additions & 70 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "next lint",
10-
"test": "vitest",
10+
"test": "vitest run",
11+
"test:watch": "vitest",
1112
"test:e2e": "playwright test"
1213
},
1314
"dependencies": {
@@ -33,6 +34,7 @@
3334
"remark-gfm": "^4.0.1",
3435
"web-push": "^3.6.7",
3536
"swr": "^2.4.2",
37+
"zod": "^3.23.0",
3638
"zustand": "^5.0.14"
3739
},
3840
"overrides": {

src/__tests__/duplicate-invoice.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ vi.mock("@/lib/invoiceHistory", () => ({
110110
recordInvoiceHistory: vi.fn(),
111111
}));
112112

113+
vi.mock("@/components/SplitCalculator", () => ({
114+
__esModule: true,
115+
default: () => null,
116+
}));
117+
118+
vi.mock("@/hooks/useOfflineDraftAutosave", () => ({
119+
useOfflineDraftAutosave: () => ({
120+
isOffline: false,
121+
discardDraft: vi.fn(),
122+
}),
123+
}));
124+
125+
vi.mock("@/lib/offlineDraftDB", () => ({
126+
getOrCreateLocalUserId: () => "local-user-id",
127+
listDraftsForUser: vi.fn().mockResolvedValue([]),
128+
}));
129+
130+
vi.mock("@/components/invoice/DraftRecoveryBanner", () => ({
131+
__esModule: true,
132+
default: () => null,
133+
}));
134+
135+
vi.mock("@/components/invoice/TxImportPanel", () => ({
136+
__esModule: true,
137+
default: () => null,
138+
}));
139+
140+
vi.mock("@/lib/templateSharing", () => ({
141+
decodeTemplate: vi.fn(),
142+
}));
143+
113144
describe("NewInvoicePage — URL pre-fill (clone mode)", () => {
114145
beforeEach(() => {
115146
mockSearchParams.clear();

src/app/api/invoices/[id]/route.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { safeParseSplitMeta, type SplitMetaInput } from "@/lib/splitMetaSchema";
3+
4+
interface SplitMetaStore {
5+
[invoiceId: string]: SplitMetaInput;
6+
}
7+
8+
let memoryStore: SplitMetaStore = {};
9+
10+
export async function PATCH(
11+
request: NextRequest,
12+
{ params }: { params: { id: string } }
13+
) {
14+
try {
15+
const invoiceId = params.id;
16+
const rawBody = await request.json();
17+
18+
const { splitMeta } = rawBody as { splitMeta?: unknown };
19+
20+
if (splitMeta === undefined || splitMeta === null) {
21+
return NextResponse.json(
22+
{ error: "Missing splitMeta in request body" },
23+
{ status: 400 }
24+
);
25+
}
26+
27+
const parseResult = safeParseSplitMeta(splitMeta);
28+
if (!parseResult.success) {
29+
return NextResponse.json(
30+
{
31+
error: "Invalid splitMeta payload",
32+
details: parseResult.error?.issues,
33+
},
34+
{ status: 422 }
35+
);
36+
}
37+
38+
memoryStore[invoiceId] = parseResult.data!;
39+
40+
return NextResponse.json(
41+
{
42+
success: true,
43+
invoiceId,
44+
splitMeta: parseResult.data,
45+
},
46+
{ status: 200 }
47+
);
48+
} catch (error) {
49+
console.error("SplitMeta save error:", error);
50+
return NextResponse.json(
51+
{
52+
error: "Failed to persist splitMeta",
53+
details: error instanceof Error ? error.message : String(error),
54+
},
55+
{ status: 500 }
56+
);
57+
}
58+
}
59+
60+
export async function GET(
61+
_request: NextRequest,
62+
{ params }: { params: { id: string } }
63+
) {
64+
try {
65+
const invoiceId = params.id;
66+
const splitMeta = memoryStore[invoiceId];
67+
68+
return NextResponse.json(
69+
{
70+
invoiceId,
71+
splitMeta: splitMeta ?? null,
72+
},
73+
{ status: 200 }
74+
);
75+
} catch (error) {
76+
console.error("SplitMeta fetch error:", error);
77+
return NextResponse.json(
78+
{
79+
error: "Failed to fetch splitMeta",
80+
details: error instanceof Error ? error.message : String(error),
81+
},
82+
{ status: 500 }
83+
);
84+
}
85+
}

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import RecipientPayoutTracker from "@/components/RecipientPayoutTracker";
3535
import CloneLineageTree from "@/components/CloneLineageTree";
3636
import CountdownTimer from "@/components/CountdownTimer";
3737
import SplitCalculator from "@/components/SplitCalculator";
38+
import type { SplitMeta } from "@/hooks/useSplitCalculator";
3839
import ActivityFeed from "@/components/ActivityFeed";
3940
import InstallmentTracker from "@/components/InstallmentTracker";
4041
import InstallmentPanel from "@/components/InstallmentPanel";
@@ -92,6 +93,7 @@ export default function InvoiceDetailPage({ params }: Props) {
9293
const [publicKey, setPublicKey] = useState<string | null>(null);
9394
const [error, setError] = useState<string | null>(null);
9495
const [loading, setLoading] = useState(true);
96+
const [loadedSplitMeta, setLoadedSplitMeta] = useState<SplitMeta | null>(null);
9597

9698
// Live stream
9799
const {
@@ -187,6 +189,15 @@ export default function InvoiceDetailPage({ params }: Props) {
187189
}
188190
const inv = await splitClient.getInvoice(id);
189191
setInvoice(inv);
192+
try {
193+
const res = await fetch(`/api/invoices/${id}`);
194+
if (res.ok) {
195+
const json = await res.json();
196+
if (json.splitMeta) setLoadedSplitMeta(json.splitMeta);
197+
}
198+
} catch {
199+
// ignore splitMeta fetch failures; invoice still loads
200+
}
190201
setLoading(false);
191202
};
192203

@@ -668,7 +679,10 @@ export default function InvoiceDetailPage({ params }: Props) {
668679
</InvoiceSection>
669680

670681
{/* Split Calculator */}
671-
{invoice.status === "Pending" && <SplitCalculator invoice={invoice} />}
682+
<SplitCalculator
683+
splitMeta={loadedSplitMeta ?? ((invoice as any).splitMeta as SplitMeta | undefined)}
684+
readOnly
685+
/>
672686

673687
<ActivityFeed
674688
invoice={{
@@ -769,16 +783,19 @@ export default function InvoiceDetailPage({ params }: Props) {
769783
invoice={invoice}
770784
total={total}
771785
publicKey={publicKey}
772-
onPay={async (amount, email) => {
773-
const result = await splitClient.pay({ payer: publicKey, invoiceId: id, amount });
786+
onPay={async (amount, email, options, mfaToken) => {
787+
const result = await splitClient.pay({
788+
payer: publicKey,
789+
invoiceId: id,
790+
amount,
791+
metadata: mfaToken ? { mfaToken } : undefined,
792+
});
774793
fetch("/api/cron/funding-thresholds", {
775794
method: "POST",
776795
headers: { "Content-Type": "application/json" },
777796
body: JSON.stringify({ invoiceId: id }),
778797
}).catch(() => null);
779798
return result;
780-
onPay={async (amount, email, options, mfaToken) => {
781-
return splitClient.pay({ payer: publicKey, invoiceId: id, amount, metadata: mfaToken ? { mfaToken } : undefined });
782799
}}
783800
onClose={() => setShowPayModal(false)}
784801
/>

src/app/invoice/new/page.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import {
2424
listDraftsForUser,
2525
type StoredDraft,
2626
} from "@/lib/offlineDraftDB";
27+
import SplitCalculator from "@/components/SplitCalculator";
28+
import {
29+
calculateSplit,
30+
type SplitMeta,
31+
} from "@/hooks/useSplitCalculator";
2732

2833
const RecipientForm = dynamic(() => import("@/components/RecipientForm"), { ssr: false });
2934
const TemplateManager = dynamic(() => import("@/components/TemplateManager"), { ssr: false });
@@ -99,6 +104,7 @@ function NewInvoiceForm() {
99104
const [recurring, setRecurring] = useState(false);
100105
const [intervalDays, setIntervalDays] = useState<7 | 30>(7);
101106
const [submitting, setSubmitting] = useState(false);
107+
const [splitMeta, setSplitMeta] = useState<SplitMeta | null>(null);
102108

103109
const fromId = searchParams.get("from");
104110
const deadlineParam = searchParams.get("deadline");
@@ -142,6 +148,7 @@ function NewInvoiceForm() {
142148
deadlineDays,
143149
recurring,
144150
intervalDays,
151+
splitMeta,
145152
};
146153

147154
const { isOffline: draftOffline, discardDraft } = useOfflineDraftAutosave(
@@ -157,6 +164,9 @@ function NewInvoiceForm() {
157164
setDeadlineDays(recoveredDraft.data.deadlineDays);
158165
setRecurring(recoveredDraft.data.recurring);
159166
setIntervalDays(recoveredDraft.data.intervalDays);
167+
if ((recoveredDraft.data as any).splitMeta) {
168+
setSplitMeta((recoveredDraft.data as any).splitMeta);
169+
}
160170
if (draftUserId) {
161171
import("@/lib/offlineDraftDB").then(({ deleteDraft }) =>
162172
deleteDraft(draftUserId, recoveredDraft.draftId)
@@ -363,6 +373,16 @@ function NewInvoiceForm() {
363373
setStepErrors((prev) => ({ ...prev, [s]: "Each recipient needs a valid G... address and positive amount" }));
364374
return false;
365375
}
376+
if (splitMeta && splitMeta.recipients.length > 0) {
377+
const splitResult = calculateSplit(splitMeta.totalAmount, splitMeta.recipients, splitMeta.assetCode);
378+
if (!splitResult.validation.isValid) {
379+
setStepErrors((prev) => ({
380+
...prev,
381+
[s]: splitResult.validation.errorMessage ?? "Split calculator has invalid configuration",
382+
}));
383+
return false;
384+
}
385+
}
366386
setStepErrors((prev) => ({ ...prev, [s]: null }));
367387
return true;
368388
}
@@ -421,6 +441,13 @@ function NewInvoiceForm() {
421441
recipients.map((r) => ({ address: r.address, amount: r.amount }))
422442
);
423443
addToast(`Invoice #${invoiceId} created`, "success");
444+
if (splitMeta && splitMeta.recipients.length > 0) {
445+
fetch(`/api/invoices/${invoiceId}`, {
446+
method: "PATCH",
447+
headers: { "Content-Type": "application/json" },
448+
body: JSON.stringify({ splitMeta }),
449+
}).catch(() => null);
450+
}
424451
setTxModal({ txHash, invoiceId });
425452
} else {
426453
const { invoiceId, txHash } = await splitClient.createInvoice({
@@ -440,6 +467,13 @@ function NewInvoiceForm() {
440467
amount: equalSplit ? (perRecipientAmount ?? "0") : r.amount,
441468
}))
442469
);
470+
if (splitMeta && splitMeta.recipients.length > 0) {
471+
fetch(`/api/invoices/${invoiceId}`, {
472+
method: "PATCH",
473+
headers: { "Content-Type": "application/json" },
474+
body: JSON.stringify({ splitMeta }),
475+
}).catch(() => null);
476+
}
443477
setTxModal({ txHash, invoiceId });
444478
}
445479
discardDraft();
@@ -680,6 +714,11 @@ function NewInvoiceForm() {
680714
/>
681715
</ChangedField>
682716
</div>
717+
718+
<SplitCalculator
719+
initialTotal={equalSplit ? totalAmount : recipients.reduce((s, r) => s + parseFloat(r.amount || "0"), 0).toFixed(7)}
720+
onSplitMetaChange={setSplitMeta}
721+
/>
683722
</div>
684723
);
685724

0 commit comments

Comments
 (0)