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
17 changes: 17 additions & 0 deletions e2e/create-invoice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ test.describe('Create invoice flow', () => {
await expect(createPage.totalAmountInput).toHaveValue('200');
});

test('back button returns to previous step without losing entered data', async ({ page }) => {
const createPage = new CreateInvoicePage(page);
await createPage.goto();

await createPage.fillRecipient('GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJQDP7DC3K2YSJEIPQ76PQXI');
await createPage.fillAmount('150');
await createPage.clickNext();

await expect(createPage.backButton).toBeVisible();
await createPage.clickBack();

await expect(createPage.recipientInput).toHaveValue(
'GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJQDP7DC3K2YSJEIPQ76PQXI',
);
await expect(createPage.amountInput).toHaveValue('150');
});

test('fill form and submit redirects', async ({ page }) => {
const createPage = new CreateInvoicePage(page);
await createPage.goto();
Expand Down
138 changes: 138 additions & 0 deletions src/hooks/__tests__/usePaymentSubmission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";

const mockSimulateTransaction = vi.hoisted(() => vi.fn());

vi.mock("@/lib/simulateTx", () => ({
simulateTransaction: mockSimulateTransaction,
}));

import {
usePaymentSubmission,
isRateLockExpired,
} from "../usePaymentSubmission";

describe("isRateLockExpired", () => {
it("returns false when there is no rate lock", () => {
expect(isRateLockExpired(null)).toBe(false);
expect(isRateLockExpired(undefined)).toBe(false);
});

it("returns false before expiry", () => {
const lock = { currency: "USD", rate: 0.1, expiresAt: 1000 };
expect(isRateLockExpired(lock, 500)).toBe(false);
});

it("returns true at or after expiry", () => {
const lock = { currency: "USD", rate: 0.1, expiresAt: 1000 };
expect(isRateLockExpired(lock, 1000)).toBe(true);
expect(isRateLockExpired(lock, 1500)).toBe(true);
});
});

describe("usePaymentSubmission", () => {
beforeEach(() => {
vi.clearAllMocks();
if (typeof window !== "undefined") {
localStorage.clear();
}
});

it("initializes with a clean state", () => {
const { result } = renderHook(() => usePaymentSubmission());
expect(result.current.state).toEqual({
showSimulationPreview: false,
isSimulating: false,
simulationResult: null,
simulationError: null,
rateLockExpired: false,
});
});

it("runs a simulation and stores the result on initiateDemoPayment", async () => {
mockSimulateTransaction.mockResolvedValue({
success: true,
fee: 100,
effects: [],
});

const { result } = renderHook(() => usePaymentSubmission());
const onConfirm = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();

await act(async () => {
await result.current.initiateDemoPayment("xdr-123", onConfirm, onCancel);
});

expect(mockSimulateTransaction).toHaveBeenCalledWith("xdr-123");
expect(result.current.state.showSimulationPreview).toBe(true);
expect(result.current.state.isSimulating).toBe(false);
expect(result.current.state.simulationResult).toEqual({
success: true,
fee: 100,
effects: [],
});
expect(result.current.state.simulationError).toBeNull();
});

it("surfaces a simulation error without throwing", async () => {
mockSimulateTransaction.mockRejectedValue(new Error("RPC unreachable"));

const { result } = renderHook(() => usePaymentSubmission());

await act(async () => {
await result.current.initiateDemoPayment(
"xdr-bad",
vi.fn(),
vi.fn(),
);
});

expect(result.current.state.isSimulating).toBe(false);
expect(result.current.state.simulationError).toBe("RPC unreachable");
});

it("blocks initiation when the rate lock has already expired", async () => {
const { result } = renderHook(() => usePaymentSubmission());
const expiredLock = { currency: "USD", rate: 0.1, expiresAt: 1 };

await act(async () => {
await result.current.initiateDemoPayment(
"xdr-123",
vi.fn(),
vi.fn(),
{ rateLock: expiredLock },
);
});

expect(mockSimulateTransaction).not.toHaveBeenCalled();
expect(result.current.state.rateLockExpired).toBe(true);
expect(result.current.state.simulationError).toMatch(/rate lock/i);
});

it("resetState clears simulation state back to defaults", async () => {
mockSimulateTransaction.mockResolvedValue({
success: true,
fee: 1,
effects: [],
});
const { result } = renderHook(() => usePaymentSubmission());

await act(async () => {
await result.current.initiateDemoPayment("xdr", vi.fn(), vi.fn());
});
expect(result.current.state.showSimulationPreview).toBe(true);

act(() => {
result.current.resetState();
});

expect(result.current.state).toEqual({
showSimulationPreview: false,
isSimulating: false,
simulationResult: null,
simulationError: null,
rateLockExpired: false,
});
});
});
124 changes: 124 additions & 0 deletions src/lib/invoiceStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
canTransition,
canReleaseInvoice,
getReleaseCheck,
isReleaseBlocked,
transitionToDisputed,
transitionToReleased,
STATUS_TRANSITIONS,
} from "./invoiceStatus";
import { createMilestone, addCondition } from "./invoiceMilestones";
import { createDispute } from "./arbitrators";

describe("invoiceStatus state machine", () => {
beforeEach(() => {
if (typeof window !== "undefined") {
localStorage.clear();
}
});

describe("canTransition", () => {
it("allows transitions listed in STATUS_TRANSITIONS", () => {
for (const from of Object.keys(STATUS_TRANSITIONS) as Array<
keyof typeof STATUS_TRANSITIONS
>) {
for (const to of STATUS_TRANSITIONS[from]) {
expect(canTransition(from, to)).toBe(true);
}
}
});

it("rejects transitions that are not listed", () => {
expect(canTransition("Pending", "Released")).toBe(false);
expect(canTransition("Released", "Funded")).toBe(false);
});

it("rejects any transition out of Archived", () => {
expect(canTransition("Archived", "Pending")).toBe(false);
expect(STATUS_TRANSITIONS.Archived).toHaveLength(0);
});
});

describe("getReleaseCheck / canReleaseInvoice / isReleaseBlocked", () => {
it("allows release when there are no milestones and no disputes", () => {
const check = getReleaseCheck("inv-no-milestones");
expect(check.allowed).toBe(true);
expect(check.reasons).toHaveLength(0);
expect(canReleaseInvoice("inv-no-milestones")).toBe(true);
expect(isReleaseBlocked("inv-no-milestones")).toBe(false);
});

it("blocks release when a required milestone condition is unmet", () => {
const invoiceId = "inv-with-milestone";
const milestone = createMilestone(invoiceId, 100);
addCondition(invoiceId, milestone.id, {
type: "deliverable",
description: "Deliver final asset",
required: true,
});

const check = getReleaseCheck(invoiceId);
expect(check.allowed).toBe(false);
expect(check.reasons).toContain(
"Milestone conditions have not been satisfied",
);
expect(canReleaseInvoice(invoiceId)).toBe(false);
expect(isReleaseBlocked(invoiceId)).toBe(true);
});

it("blocks release when the invoice has an active dispute", () => {
const invoiceId = "inv-disputed";
createDispute({
invoiceId,
initiator: "GRAISER",
respondent: "GCOUNTER",
reason: "Work not delivered",
description: "Deliverable was never provided.",
});

const check = getReleaseCheck(invoiceId);
expect(check.allowed).toBe(false);
expect(check.reasons).toContain("Invoice has an unresolved dispute");
});
});

describe("transitionToDisputed", () => {
it("returns Disputed when transition is valid", () => {
expect(transitionToDisputed("Funded")).toBe("Disputed");
});

it("throws when transition is invalid", () => {
expect(() => transitionToDisputed("Archived")).toThrow(
/Invalid status transition/,
);
});
});

describe("transitionToReleased", () => {
it("returns Released when transition is valid and release is unblocked", () => {
const invoiceId = "inv-release-ok";
expect(transitionToReleased(invoiceId, "Funded")).toBe("Released");
});

it("throws when the status transition itself is invalid", () => {
expect(() => transitionToReleased("inv-x", "Pending")).toThrow(
/Invalid status transition/,
);
});

it("throws when release is blocked by an unmet milestone", () => {
const invoiceId = "inv-release-blocked";
const milestone = createMilestone(invoiceId, 100);
addCondition(invoiceId, milestone.id, {
type: "approval",
description: "Client sign-off",
required: true,
});

expect(() => transitionToReleased(invoiceId, "Funded")).toThrow(
/Release blocked/,
);
});
});
});
20 changes: 11 additions & 9 deletions src/lib/invoiceStatus.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {
areMilestoneConditionsSatisfied,
getInvoiceMilestones,
} from "./invoiceMilestones";
import { getActiveDispute } from "./invoiceDisputes";
import { getMilestones, canReleaseMilestone } from "./invoiceMilestones";
import { getDisputesByInvoice, isDisputeActive } from "./arbitrators";

export type InvoiceStatus =
| "Pending"
Expand Down Expand Up @@ -125,13 +122,18 @@ export function canReleaseInvoice(invoiceId: string): boolean {
export function getReleaseCheck(invoiceId: string): ReleaseCheckResult {
const reasons: string[] = [];

const dispute = getActiveDispute(invoiceId);
if (dispute) {
const activeDispute = getDisputesByInvoice(invoiceId).some((d) =>
isDisputeActive(d),
);
if (activeDispute) {
reasons.push("Invoice has an unresolved dispute");
}

const milestones = getInvoiceMilestones(invoiceId);
if (milestones.length > 0 && !areMilestoneConditionsSatisfied(invoiceId)) {
const milestones = getMilestones(invoiceId);
const unmetMilestones = milestones.filter(
(m) => !m.released && !canReleaseMilestone(m),
);
if (unmetMilestones.length > 0) {
reasons.push("Milestone conditions have not been satisfied");
}

Expand Down
Loading