From 2569a3a13d8001e5e29bfe6c8bf5ae003a952e9c Mon Sep 17 00:00:00 2001 From: Johnero542 Date: Sun, 18 Oct 2026 21:29:05 +0100 Subject: [PATCH] Fix invoice state management and validation issues Addresses four invoice lifecycle bugs: - Prevent paid invoices from reverting to pending (#872) Add state validation to reject cancel_invoiced action on Paid invoices Ensures paid invoices remain in paid/refund states only - Prevent payment on cancelled invoices (#871) Add validation check to reject request_refund on Cancelled invoices Returns 409 error when attempting to pay cancelled invoice - Prevent payment on expired invoices (#873) Check invoice expiration against due_date before allowing request_refund Compares current time to stored due_date from invoice record Rejects payment on invoices past their due date - Include due_date in invoice detail response (#875) Modified GET /invoices/:id to return actual status from contract Added due_date field (Unix timestamp) to invoice detail response Fetches invoice record from database for complete invoice metadata All invoice state transitions now validate against current status and expiration. Closes #871, #872, #873, #875 --- .../src/routes/invoice-actions.ts | 27 +++++++++++++++ comebackhere-backend/src/routes/invoices.ts | 33 ++++++++----------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/comebackhere-backend/src/routes/invoice-actions.ts b/comebackhere-backend/src/routes/invoice-actions.ts index c5533fc..0c26f08 100644 --- a/comebackhere-backend/src/routes/invoice-actions.ts +++ b/comebackhere-backend/src/routes/invoice-actions.ts @@ -5,6 +5,7 @@ import { requireEnv } from "../lib/env.js" import { asyncHandler, ContractError, parseContractErrorCode } from "../lib/errors.js" import { validateParams } from "../middleware/validate.js" import { invoiceActionIdParamSchema } from "../schemas/index.js" +import { connectMongo, getInvoicesCollection } from "../db/mongo.js" const router = Router() @@ -43,6 +44,14 @@ export async function getInvoiceStatus( } } +export async function isInvoiceExpired(invoiceId: string): Promise { + const db = await connectMongo() + const invoice = await getInvoicesCollection(db).findOne({ invoice_id: invoiceId }) + if (!invoice) return false + const now = Math.floor(Date.now() / 1000) + return now > invoice.due_date +} + export async function applyInvoiceAction( action: InvoiceAction, invoiceId: string, @@ -52,6 +61,24 @@ export async function applyInvoiceAction( const signer = Keypair.fromSecret(env.signerSecret) const currentStatus = await getInvoiceStatus(invoiceId, env, client) + // Prevent paid invoices from reverting to pending (#872) + if (action === "cancel_invoiced" && currentStatus === "Paid") { + throw new ContractError(3, "Cannot cancel a paid invoice", 409) + } + + // Prevent payment on cancelled invoices (#871) + if (action === "request_refund" && currentStatus === "Cancelled") { + throw new ContractError(3, "Cannot pay a cancelled invoice", 409) + } + + // Prevent payment on expired invoices (#873) + if (action === "request_refund") { + const isExpired = await isInvoiceExpired(invoiceId) + if (isExpired) { + throw new ContractError(3, "Cannot pay an expired invoice", 409) + } + } + if (action === "request_refund" && currentStatus !== "Paid") { if (currentStatus === "RefundRequested") { throw new ContractError(11, "A refund has already been requested", 409) diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index d728079..ab6c1ac 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -7,6 +7,7 @@ import { asyncHandler, NotFoundError } from "../lib/errors.js" import { cacheGet, cacheSet } from "../lib/cache.js" import { validateBody, validateParams, validateQuery } from "../middleware/validate.js" import { createInvoiceSchema, invoiceIdParamSchema, invoiceListQuerySchema } from "../schemas/index.js" +import { getInvoiceStatus } from "./invoice-actions.js" const router = Router() @@ -527,31 +528,23 @@ router.get("/", validateQuery(invoiceListQuerySchema), asyncHandler(async (req: router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req: Request, res: Response) => { const { id } = req.params - const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID" }) + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", rpcUrl: "SOROBAN_RPC_URL", signerSecret: "SIGNER_SECRET_KEY", networkPassphrase: "NETWORK_PASSPHRASE" }) - const server = new SorobanRpc.Server(env.rpcUrl) - const contract = new Contract(env.invoiceContractId) - - // Build a read-only ledger entry query for the invoice - const ledgerKey = contract.getFootprint() - void ledgerKey // used below via getLedgerEntries - - // Query the contract's ledger entry directly - const entries = await server.getLedgerEntries( - xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract: new Contract(env.invoiceContractId).address().toScAddress(), - key: nativeToScVal(BigInt(id), { type: "u64" }), - durability: xdr.ContractDataDurability.persistent(), - }) - ) - ) + const db = await connectMongo() + const invoice = await getInvoicesCollection(db).findOne({ invoice_id: id }) - if (!entries.entries.length) { + if (!invoice) { throw new NotFoundError("Invoice not found") } - res.json({ invoice_id: id, status: "Pending" }) + const status = await getInvoiceStatus(id, { + rpcUrl: env.rpcUrl, + invoiceContractId: env.invoiceContractId, + signerSecret: env.signerSecret, + networkPassphrase: env.networkPassphrase, + }) + + res.json({ invoice_id: id, status, due_date: invoice.due_date }) })) /**