From e8ff74181200a4bfecedc5d020154aa1742dde6e Mon Sep 17 00:00:00 2001 From: Joel234-png Date: Sun, 18 Oct 2026 22:35:13 +0100 Subject: [PATCH] Fix invoice lifecycle and pagination issues Addresses four invoice management bugs: - Invoice list ordering inconsistency between paginated requests (#876) Add _id field as stable tiebreaker in sort order to ensure consistent pagination across multiple requests. Sort by created_at, invoice_id, _id. - Invoice status does not update after successful settlement (#874) Add syncInvoiceStatus function that queries contract status on GET /:id and updates MongoDB with the current state from Soroban smart contract. Ensures invoice detail response always reflects settlement changes. - Repeated invoice creation produces conflicting invoice identifier (#877) Add duplicate invoice ID check before insertion. If ID already exists, return 409 Conflict error and ask client to retry to generate new ID. - Invoice creation accepts due date outside supported range (#879) Add upper bound validation to futureTimestamp schema. Due date must be within 10 years from now (315,360,000 seconds). Prevents invalid dates. All invoice operations now maintain data consistency and proper constraints. Closes #874, #876, #877, #879 --- comebackhere-backend/src/routes/invoices.ts | 74 +++++++++++++++------ comebackhere-backend/src/schemas/index.ts | 3 + 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index d728079..c60b887 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -462,7 +462,7 @@ router.get("/", validateQuery(invoiceListQuerySchema), asyncHandler(async (req: } : filter - let findCursor = collection.find(pageFilter).sort({ created_at: -1, invoice_id: -1 }) + let findCursor = collection.find(pageFilter).sort({ created_at: -1, invoice_id: -1, _id: -1 }) if (legacyPagination) findCursor = findCursor.skip(offset) const rows = await findCursor.limit(limit + 1).toArray() const hasMore = rows.length > limit @@ -524,34 +524,56 @@ router.get("/", validateQuery(invoiceListQuerySchema), asyncHandler(async (req: * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params +/** + * Sync invoice status from contract to database (#874). + * Called before returning invoice details to ensure status reflects current contract state. + */ +async function syncInvoiceStatus( + invoiceId: string, + collection: ReturnType, + env: { invoiceContractId: string; rpcUrl: string; signerSecret: string; networkPassphrase: string } +): Promise { + try { + const { getInvoiceStatus } = await import("./invoice-actions.js") + const contractStatus = await getInvoiceStatus(invoiceId, { + rpcUrl: env.rpcUrl, + invoiceContractId: env.invoiceContractId, + signerSecret: env.signerSecret, + networkPassphrase: env.networkPassphrase, + }) + + // Update the database with the current contract status + await collection.updateOne( + { invoice_id: invoiceId }, + { $set: { status: contractStatus, updated_at: new Date() } } + ) - const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID" }) + return contractStatus + } catch (err) { + // If we can't fetch from contract, return null to fall back to DB status + return null + } +} - const server = new SorobanRpc.Server(env.rpcUrl) - const contract = new Contract(env.invoiceContractId) +router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params - // Build a read-only ledger entry query for the invoice - const ledgerKey = contract.getFootprint() - void ledgerKey // used below via getLedgerEntries + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", rpcUrl: "SOROBAN_RPC_URL", signerSecret: "SIGNER_SECRET_KEY", networkPassphrase: "NETWORK_PASSPHRASE" }) - // 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 collection = getInvoicesCollection(db) - if (!entries.entries.length) { + // Get the invoice from database + const invoice = await collection.findOne({ invoice_id: id }) + if (!invoice) { throw new NotFoundError("Invoice not found") } - res.json({ invoice_id: id, status: "Pending" }) + // Sync status from contract to ensure it reflects any settlement (#874) + const contractStatus = await syncInvoiceStatus(id, collection, env) + const status = contractStatus || invoice.status + + res.json({ invoice_id: id, status }) })) /** @@ -725,6 +747,16 @@ router.post("/", requireMerchantApiKey, validateBody(createInvoiceSchema), async const db = await connectMongo() const collection = getInvoicesCollection(db) + + // Check for duplicate invoice ID to prevent conflicts (#877) + const existing = await collection.findOne({ invoice_id: result.invoice_id }) + if (existing) { + throw Object.assign( + new Error(`Invoice ID ${result.invoice_id} already exists; please retry to generate a new ID`), + { status: 409 } + ) + } + const now = new Date() await collection.insertOne({ invoice_id: result.invoice_id, diff --git a/comebackhere-backend/src/schemas/index.ts b/comebackhere-backend/src/schemas/index.ts index 762ad4d..59c63e3 100644 --- a/comebackhere-backend/src/schemas/index.ts +++ b/comebackhere-backend/src/schemas/index.ts @@ -28,6 +28,9 @@ const futureTimestamp = z .refine((val) => val > Math.floor(Date.now() / 1000), { message: "due_date must be in the future", }) + .refine((val) => val <= Math.floor(Date.now() / 1000) + 315_360_000, { + message: "due_date must be within 10 years from now", + }) export const createInvoiceSchema = z.object({ token: z.string().min(1, "token is required"),