diff --git a/.gitignore b/.gitignore index aed438a..b43e530 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,52 @@ +# ── Secrets & environment ──────────────────────────────────────────────────── # Environment files .env .env.local .env.mainnet .env.testnet +.env.local +.env.*.local + +# ── Deployed contract addresses (environment-specific) ─────────────────────── +artifacts/addresses.json + +# ── Dependencies ───────────────────────────────────────────────────────────── +node_modules/ +**/node_modules/ + +# ── Build outputs ───────────────────────────────────────────────────────────── +dist/ +build/ +out/ +**/dist/ + +# ── Test artifacts ──────────────────────────────────────────────────────────── +coverage/ +**/coverage/ + +# Vitest / Jest snapshots +**/__snapshots__/ +**/*.snap + +# Playwright +**/playwright-report/ +**/test-results/ +**/blob-report/ +**/playwright/.cache/ + +# ── TypeScript cache ────────────────────────────────────────────────────────── +*.tsbuildinfo +**/tsconfig.tsbuildinfo + +# ── Vite cache ──────────────────────────────────────────────────────────────── +**/.vite/ + +# ── Rust / Cargo build artifacts ───────────────────────────────────────────── +**/target/ + +# ── OS / editor noise ──────────────────────────────────────────────────────── +.DS_Store +Thumbs.db .env.*.local # Build output @@ -97,6 +141,21 @@ playwright-report/ *.swo *~ +# ── IDE-specific ────────────────────────────────────────────────────────────── +.vscode/settings.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# ── Logs ────────────────────────────────────────────────────────────────────── +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* # Coverage reports comebackhere-frontend/coverage/ frontend/coverage/ diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index d728079..ed11ea5 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -84,6 +84,8 @@ export async function createInvoice( const sendResult = await client.sendTransaction(prepared) if (sendResult.status === "ERROR") { throw Object.assign( + new Error(`Soroban submission failed: ${(sendResult as any).errorResult?.toXDR("base64")}`), + { status: 422, tx_hash: (sendResult as any).hash as string | undefined } new Error( `Soroban submission failed: ${(sendResult as { errorResult?: { toXDR: (format: string) => string } }).errorResult?.toXDR("base64")}`, ), @@ -100,11 +102,11 @@ export async function createInvoice( } if (!getResult || getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { - throw Object.assign(new Error("Transaction confirmation timeout"), { status: 504 }) + throw Object.assign(new Error("Transaction confirmation timeout"), { status: 504, tx_hash: hash }) } if (getResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw Object.assign(new Error("Soroban transaction failed"), { status: 422 }) + throw Object.assign(new Error("Soroban transaction failed"), { status: 422, tx_hash: hash }) } const returnVal = (getResult as SorobanRpc.Api.GetSuccessfulTransactionResponse).returnValue @@ -545,6 +547,14 @@ router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req durability: xdr.ContractDataDurability.persistent(), }) ) + res.status(201).json(result) + } catch (err: unknown) { + const status = (err as any)?.status ?? 500 + const message = err instanceof Error ? err.message : String(err) + const tx_hash = (err as any)?.tx_hash as string | undefined + const body: { error: string; tx_hash?: string } = { error: message } + if (tx_hash) body.tx_hash = tx_hash + res.status(status).json(body) ) if (!entries.entries.length) { diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index 5929ffe..99a421c 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -48,6 +48,33 @@ export { getBalanceCache, setBalanceCache, invalidateBalanceCache } * schema: * $ref: '#/components/schemas/ErrorResponse' */ +router.get("/pending-settlements", async (_req: Request, res: Response) => { + try { + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const records = await settlements + .find({ status: "Pending" }) + .sort({ id: 1 }) + .toArray() + + res.json( + records.map((s) => ({ + id: s.id, + merchant_address: s.merchant_address, + amount: s.amount, + approvals: s.approvals, + approval_weight: s.approval_weight, + status: s.status, + hold_reason: s.hold_reason, + proposed_tx_hash: s.proposed_tx_hash ?? null, + executed_tx_hash: s.executed_tx_hash ?? null, + })), + ) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + res.status(500).json({ error: message }) + } +}) router.get("/pending-settlements", asyncHandler(async (_req: Request, res: Response) => { const database = await connectMongo() const settlements = getSettlementsCollection(database) diff --git a/comebackhere-backend/src/tests/invoices.test.ts b/comebackhere-backend/src/tests/invoices.test.ts index 8b8b070..d4e945c 100644 --- a/comebackhere-backend/src/tests/invoices.test.ts +++ b/comebackhere-backend/src/tests/invoices.test.ts @@ -187,6 +187,24 @@ describe("createInvoice — Soroban interaction", () => { .rejects.toMatchObject({ message: expect.stringMatching(/submission failed/), status: 422 }) }) + it("throws 422 with tx_hash when getTransaction returns FAILED", async () => { + const client = makeMockClient({ + getAccount: vi.fn().mockResolvedValue(fakeAccount), + simulateTransaction: vi.fn().mockResolvedValue(PARSED_SIM_SUCCESS), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "txhash-failed" }), + getTransaction: vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + latestLedger: 1, + latestLedgerCloseTime: 0, + oldestLedger: 1, + oldestLedgerCloseTime: 0, + }), + }) + + await expect(createInvoice(VALID_BODY, client, CONTRACT_ID, SIGNER_SECRET, NETWORK)) + .rejects.toMatchObject({ message: expect.stringMatching(/transaction failed/), status: 422, tx_hash: "txhash-failed" }) + }) + it("throws 504 when confirmation times out", async () => { const client = makeMockClient({ getAccount: vi.fn().mockResolvedValue(fakeAccount), diff --git a/comebackhere-frontend/src/components/InvoicePayment.tsx b/comebackhere-frontend/src/components/InvoicePayment.tsx index 83359d4..51183d2 100644 --- a/comebackhere-frontend/src/components/InvoicePayment.tsx +++ b/comebackhere-frontend/src/components/InvoicePayment.tsx @@ -269,7 +269,7 @@ export function InvoicePayment() {
{t("invoiceCard.expiry")} - {new Date(invoice.expires_at * 1000).toLocaleString()} + {new Date(invoice.expires_at * 1000).toISOString().replace("T", " ").slice(0, 19) + " UTC"}
diff --git a/comebackhere-frontend/src/components/TransactionHistory.tsx b/comebackhere-frontend/src/components/TransactionHistory.tsx index 095f652..a2d7171 100644 --- a/comebackhere-frontend/src/components/TransactionHistory.tsx +++ b/comebackhere-frontend/src/components/TransactionHistory.tsx @@ -15,6 +15,11 @@ const EVENT_LABELS: Record = { const PAGE_SIZE = 5 +/** Format a millisecond timestamp as a UTC date-time string. */ +function formatUTC(ms: number): string { + return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC" +} + function buildEvents(invoice: Invoice): TransactionEvent[] { const now = Date.now() const expiresAt = invoice.expires_at * 1000 @@ -245,7 +250,7 @@ export function TransactionHistory({ invoice }: { invoice: Invoice }) {
{EVENT_LABELS[entry.type]} - {new Date(entry.timestamp).toLocaleString()} + {formatUTC(entry.timestamp)}

{entry.description}

diff --git a/frontend/src/components/InvoiceSearchFilter.tsx b/frontend/src/components/InvoiceSearchFilter.tsx index bbd9e2d..f1622c4 100644 --- a/frontend/src/components/InvoiceSearchFilter.tsx +++ b/frontend/src/components/InvoiceSearchFilter.tsx @@ -45,6 +45,17 @@ function formatDate(ts: number | null): string { return new Date(ts * 1000).toLocaleDateString(); } +/** Normalise an invoice status value to a plain lowercase string for comparison. + * Handles TypeScript string-enums (whose .valueOf() is the string) and plain + * string unions alike, so the filter works regardless of which type the caller + * passes in. */ +function normaliseStatus(s: unknown): string { + if (s == null) return ""; + return String(s).trim(); +} + +export default function InvoiceSearchFilter({ invoices }: Props) { + const [query, setQuery] = useState(""); export default function InvoiceSearchFilter({ invoices, apiUrl }: Props) { const [inputQuery, setInputQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); @@ -118,12 +129,13 @@ export default function InvoiceSearchFilter({ invoices, apiUrl }: Props) { const q = debouncedQuery.trim().toLowerCase(); const fromTs = dateFrom ? new Date(dateFrom).getTime() / 1000 : null; const toTs = dateTo ? new Date(dateTo).getTime() / 1000 + 86400 : null; + const normalisedFilter = normaliseStatus(statusFilter); return invoices.filter((inv) => { if (q && !inv.id.toLowerCase().includes(q) && !inv.merchant.toLowerCase().includes(q)) { return false; } - if (statusFilter && inv.status !== statusFilter) return false; + if (normalisedFilter && normaliseStatus(inv.status) !== normalisedFilter) return false; if (fromTs && inv.created_at !== null && inv.created_at < fromTs) return false; if (toTs && inv.created_at !== null && inv.created_at > toTs) return false; return true; diff --git a/frontend/src/components/SettlementDetail/SettlementDetail.css b/frontend/src/components/SettlementDetail/SettlementDetail.css index 8120cc7..7a2ce65 100644 --- a/frontend/src/components/SettlementDetail/SettlementDetail.css +++ b/frontend/src/components/SettlementDetail/SettlementDetail.css @@ -82,6 +82,13 @@ color: #111827; } +.settlement-detail__value--hash { + font-family: monospace; + font-size: 0.8125rem; + color: #1d4ed8; + word-break: break-all; +} + .settlement-detail__progress-section { margin-bottom: 1.5rem; } diff --git a/frontend/src/components/SettlementDetail/SettlementDetail.tsx b/frontend/src/components/SettlementDetail/SettlementDetail.tsx index 17c2b80..6b0ecc0 100644 --- a/frontend/src/components/SettlementDetail/SettlementDetail.tsx +++ b/frontend/src/components/SettlementDetail/SettlementDetail.tsx @@ -254,6 +254,28 @@ export default function SettlementDetail({ settlement, threshold, signers }: Set Total Signer Weight {totalWeight}
+ {settlement.proposed_tx_hash && ( +
+ Proposal Tx Hash + + {shorten(settlement.proposed_tx_hash)} + +
+ )} + {settlement.executed_tx_hash && ( +
+ Execution Tx Hash + + {shorten(settlement.executed_tx_hash)} + +
+ )}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 86ab85f..c2d33a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -6,6 +6,8 @@ export interface Settlement { approval_weight: number status: 'Pending' | 'Executed' | 'PartiallyExecuted' | 'OnHold' | 'Cancelled' hold_reason: string | null + proposed_tx_hash?: string + executed_tx_hash?: string } export interface SignerInfo {