Skip to content

Commit d62db7d

Browse files
SYMBAxxclaude
andauthored
feat: add invoice JSON export, deadline extension flow, webhook docs, and creator verification requests (#247-#250) (#277)
- #250: Add "Export JSON" button to AuditLogTable for structured invoice archival with schema versioning - #249: Add deadline extension request/approval flow with single-pending constraint - #248: Add webhook payload schema documentation viewer at /dev/webhook-docs - #247: Add creator verification request form with URL validation and duplicate-submission blocking Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7e17695 commit d62db7d

13 files changed

Lines changed: 1503 additions & 6 deletions
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import {
3+
submitExtensionRequest,
4+
getPendingRequest,
5+
getExtensionRequests,
6+
approveExtensionRequest,
7+
denyExtensionRequest,
8+
} from "@/lib/deadlineExtensionRequests";
9+
10+
const store: Record<string, string> = {};
11+
vi.stubGlobal("localStorage", {
12+
getItem: (key: string) => store[key] ?? null,
13+
setItem: (key: string, val: string) => { store[key] = val; },
14+
removeItem: (key: string) => { delete store[key]; },
15+
});
16+
17+
const INVOICE_ID = "inv-001";
18+
const REQUESTER = "GCEZWKZPVOPNFHIMZQ3OQNFHM2FQNBXCQ3PNHIMZQ3OQNFHM2FQNBX";
19+
const CREATOR = "GBXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABC";
20+
const FUTURE_DEADLINE = Math.floor(Date.now() / 1000) + 86400 * 7; // 7 days from now
21+
22+
beforeEach(() => {
23+
Object.keys(store).forEach((k) => delete store[k]);
24+
});
25+
26+
describe("submitExtensionRequest", () => {
27+
it("creates a request with status pending", () => {
28+
const req = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "Need more time");
29+
expect(req.status).toBe("pending");
30+
expect(req.invoiceId).toBe(INVOICE_ID);
31+
expect(req.requester).toBe(REQUESTER);
32+
expect(req.requestedDeadline).toBe(FUTURE_DEADLINE);
33+
expect(req.reason).toBe("Need more time");
34+
expect(req.id).toBeDefined();
35+
expect(req.createdAt).toBeGreaterThan(0);
36+
});
37+
});
38+
39+
describe("getPendingRequest", () => {
40+
it("returns the pending request", () => {
41+
submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "Need more time");
42+
const pending = getPendingRequest(INVOICE_ID);
43+
expect(pending).not.toBeNull();
44+
expect(pending!.status).toBe("pending");
45+
expect(pending!.invoiceId).toBe(INVOICE_ID);
46+
});
47+
48+
it("returns null when no pending request exists", () => {
49+
expect(getPendingRequest(INVOICE_ID)).toBeNull();
50+
});
51+
});
52+
53+
describe("single-pending-request constraint", () => {
54+
it("throws when submitting a second request while one is pending", () => {
55+
submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "First request");
56+
expect(() =>
57+
submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE + 3600, "Second request"),
58+
).toThrow("An extension request is already pending for this invoice.");
59+
});
60+
});
61+
62+
describe("approveExtensionRequest", () => {
63+
it("changes status to approved and sets resolvedAt/resolvedBy", () => {
64+
const req = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "Please extend");
65+
const approved = approveExtensionRequest(req.id, CREATOR);
66+
expect(approved.status).toBe("approved");
67+
expect(approved.resolvedAt).toBeGreaterThan(0);
68+
expect(approved.resolvedBy).toBe(CREATOR);
69+
});
70+
});
71+
72+
describe("denyExtensionRequest", () => {
73+
it("changes status to denied and sets resolvedAt/resolvedBy", () => {
74+
const req = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "Please extend");
75+
const denied = denyExtensionRequest(req.id, CREATOR);
76+
expect(denied.status).toBe("denied");
77+
expect(denied.resolvedAt).toBeGreaterThan(0);
78+
expect(denied.resolvedBy).toBe(CREATOR);
79+
});
80+
});
81+
82+
describe("after resolving, a new request can be submitted", () => {
83+
it("allows a new request after approval", () => {
84+
const req = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "First");
85+
approveExtensionRequest(req.id, CREATOR);
86+
expect(getPendingRequest(INVOICE_ID)).toBeNull();
87+
88+
const req2 = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE + 7200, "Second");
89+
expect(req2.status).toBe("pending");
90+
expect(getExtensionRequests(INVOICE_ID)).toHaveLength(2);
91+
});
92+
93+
it("allows a new request after denial", () => {
94+
const req = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE, "First");
95+
denyExtensionRequest(req.id, CREATOR);
96+
expect(getPendingRequest(INVOICE_ID)).toBeNull();
97+
98+
const req2 = submitExtensionRequest(INVOICE_ID, REQUESTER, FUTURE_DEADLINE + 7200, "Second");
99+
expect(req2.status).toBe("pending");
100+
expect(getExtensionRequests(INVOICE_ID)).toHaveLength(2);
101+
});
102+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Unit tests for invoiceArchiveExport.
3+
*
4+
* Covers:
5+
* 1. buildInvoiceArchive includes correct schemaVersion
6+
* 2. generateArchiveFilename produces correct format
7+
* 3. buildInvoiceArchive works with empty audit log and empty payments
8+
* 4. BigInt amounts are properly serialized as formatted strings
9+
*/
10+
11+
import { describe, it, expect, vi } from "vitest";
12+
13+
vi.mock("@stellar-split/sdk", () => ({
14+
formatAmount: (n: bigint) => (Number(n) / 10_000_000).toFixed(2),
15+
}));
16+
17+
import {
18+
buildInvoiceArchive,
19+
generateArchiveFilename,
20+
ARCHIVE_SCHEMA_VERSION,
21+
} from "@/lib/invoiceArchiveExport";
22+
23+
// ── Test Fixtures ───────────────────────────────────────────────────────────
24+
25+
function makeInvoice(overrides: Record<string, unknown> = {}) {
26+
return {
27+
id: "inv-42",
28+
creator: "GCREATOR",
29+
status: "Pending",
30+
deadline: 1_700_000_000,
31+
funded: 50_000_000n,
32+
recipients: [
33+
{ address: "GRECIPIENT1", amount: 70_000_000n },
34+
{ address: "GRECIPIENT2", amount: 30_000_000n },
35+
],
36+
...overrides,
37+
};
38+
}
39+
40+
const sampleAuditLog = [
41+
{ action: "created", actor: "GCREATOR", timestamp: 1_700_000_000 },
42+
{ action: "paid", actor: "GPAYER1", timestamp: 1_700_001_000 },
43+
];
44+
45+
const samplePayments = [
46+
{ payer: "GPAYER1", amount: 30_000_000n },
47+
{ payer: "GPAYER2", amount: 20_000_000n },
48+
];
49+
50+
// ── schemaVersion ───────────────────────────────────────────────────────────
51+
52+
describe("buildInvoiceArchive", () => {
53+
it("includes schemaVersion equal to ARCHIVE_SCHEMA_VERSION", () => {
54+
const archive = buildInvoiceArchive(
55+
makeInvoice(),
56+
sampleAuditLog,
57+
samplePayments,
58+
);
59+
expect(archive.schemaVersion).toBe(ARCHIVE_SCHEMA_VERSION);
60+
});
61+
62+
it("includes an ISO 8601 exportedAt timestamp", () => {
63+
const archive = buildInvoiceArchive(
64+
makeInvoice(),
65+
sampleAuditLog,
66+
samplePayments,
67+
);
68+
// Should be a valid ISO date string
69+
expect(() => new Date(archive.exportedAt).toISOString()).not.toThrow();
70+
expect(archive.exportedAt).toMatch(
71+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
72+
);
73+
});
74+
75+
it("works with empty audit log and empty payments", () => {
76+
const archive = buildInvoiceArchive(makeInvoice(), [], []);
77+
expect(archive.auditLog).toEqual([]);
78+
expect(archive.payments).toEqual([]);
79+
expect(archive.schemaVersion).toBe(ARCHIVE_SCHEMA_VERSION);
80+
// Invoice data should still be present
81+
expect(archive.invoice.id).toBe("inv-42");
82+
});
83+
84+
it("serializes BigInt amounts as formatted strings", () => {
85+
const archive = buildInvoiceArchive(
86+
makeInvoice(),
87+
sampleAuditLog,
88+
samplePayments,
89+
);
90+
// funded: 50_000_000n → "5.00"
91+
expect(archive.invoice.funded).toBe("5.00");
92+
// totalAmount: 70_000_000n + 30_000_000n = 100_000_000n → "10.00"
93+
expect(archive.invoice.totalAmount).toBe("10.00");
94+
// recipient amounts
95+
expect(archive.invoice.recipients[0].amount).toBe("7.00");
96+
expect(archive.invoice.recipients[1].amount).toBe("3.00");
97+
// payment amounts
98+
expect(archive.payments[0].amount).toBe("3.00");
99+
expect(archive.payments[1].amount).toBe("2.00");
100+
});
101+
102+
it("maps audit log entries correctly", () => {
103+
const archive = buildInvoiceArchive(
104+
makeInvoice(),
105+
sampleAuditLog,
106+
samplePayments,
107+
);
108+
expect(archive.auditLog).toHaveLength(2);
109+
expect(archive.auditLog[0]).toEqual({
110+
action: "created",
111+
actor: "GCREATOR",
112+
timestamp: 1_700_000_000,
113+
});
114+
});
115+
116+
it("maps payments correctly", () => {
117+
const archive = buildInvoiceArchive(
118+
makeInvoice(),
119+
sampleAuditLog,
120+
samplePayments,
121+
);
122+
expect(archive.payments).toHaveLength(2);
123+
expect(archive.payments[0].payer).toBe("GPAYER1");
124+
});
125+
});
126+
127+
// ── generateArchiveFilename ─────────────────────────────────────────────────
128+
129+
describe("generateArchiveFilename", () => {
130+
it("starts with 'invoice-' and contains the invoice id", () => {
131+
const filename = generateArchiveFilename("inv-42");
132+
expect(filename.startsWith("invoice-inv-42")).toBe(true);
133+
});
134+
135+
it("contains 'archive' in the filename", () => {
136+
const filename = generateArchiveFilename("inv-42");
137+
expect(filename).toContain("archive");
138+
});
139+
140+
it("ends with .json", () => {
141+
const filename = generateArchiveFilename("inv-42");
142+
expect(filename.endsWith(".json")).toBe(true);
143+
});
144+
145+
it("does not contain colons or dots (except .json extension)", () => {
146+
const filename = generateArchiveFilename("inv-42");
147+
const withoutExtension = filename.replace(/\.json$/, "");
148+
expect(withoutExtension).not.toContain(":");
149+
expect(withoutExtension).not.toContain(".");
150+
});
151+
});

0 commit comments

Comments
 (0)