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
74 changes: 53 additions & 21 deletions comebackhere-backend/src/routes/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof getInvoicesCollection>,
env: { invoiceContractId: string; rpcUrl: string; signerSecret: string; networkPassphrase: string }
): Promise<InvoiceStatus | null> {
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 })
}))

/**
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions comebackhere-backend/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading