Skip to content
Open
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
59 changes: 59 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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/
Expand Down
14 changes: 12 additions & 2 deletions comebackhere-backend/src/routes/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`,
),
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions comebackhere-backend/src/routes/treasury.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions comebackhere-backend/src/tests/invoices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion comebackhere-frontend/src/components/InvoicePayment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function InvoicePayment() {
<div className="detail-row">
<span className="detail-label">{t("invoiceCard.expiry")}</span>
<span className="detail-value">
{new Date(invoice.expires_at * 1000).toLocaleString()}
{new Date(invoice.expires_at * 1000).toISOString().replace("T", " ").slice(0, 19) + " UTC"}
</span>
</div>
<div className="detail-row">
Expand Down
7 changes: 6 additions & 1 deletion comebackhere-frontend/src/components/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const EVENT_LABELS: Record<TransactionEventType, string> = {

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
Expand Down Expand Up @@ -245,7 +250,7 @@ export function TransactionHistory({ invoice }: { invoice: Invoice }) {
<div className="history-item__meta">
<span className="history-item__type">{EVENT_LABELS[entry.type]}</span>
<span className="history-item__timestamp">
{new Date(entry.timestamp).toLocaleString()}
{formatUTC(entry.timestamp)}
</span>
</div>
<p className="history-item__description">{entry.description}</p>
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/InvoiceSearchFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/SettlementDetail/SettlementDetail.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/SettlementDetail/SettlementDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,28 @@ export default function SettlementDetail({ settlement, threshold, signers }: Set
<span className="settlement-detail__label">Total Signer Weight</span>
<span className="settlement-detail__value">{totalWeight}</span>
</div>
{settlement.proposed_tx_hash && (
<div className="settlement-detail__row">
<span className="settlement-detail__label">Proposal Tx Hash</span>
<span
className="settlement-detail__value settlement-detail__value--hash"
title={settlement.proposed_tx_hash}
>
{shorten(settlement.proposed_tx_hash)}
</span>
</div>
)}
{settlement.executed_tx_hash && (
<div className="settlement-detail__row">
<span className="settlement-detail__label">Execution Tx Hash</span>
<span
className="settlement-detail__value settlement-detail__value--hash"
title={settlement.executed_tx_hash}
>
{shorten(settlement.executed_tx_hash)}
</span>
</div>
)}
</div>

<div className="settlement-detail__progress-section">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading