moeru-ai/airi · error · ApiError
INTERNAL_SERVER_ERROR
INTERNAL_SERVER_ERROR
Error message
Payment order not found
What it means
claimExistingOrder locks a payment_order row FOR UPDATE using the paymentOrderId embedded in a Stripe webhook ClaimReceipt. This error means the SELECT FOR UPDATE returned no row: the order id referenced by the receipt does not exist in payment_order. The service throws createInternalError because a settleable webhook pointing at a missing order indicates corrupted data or a receipt built from a foreign/legacy row, so it refuses to guess.
Solutions
- Verify the payment_order row exists for receipt.paymentOrderId: SELECT * FROM payment_order WHERE id = '<receipt.paymentOrderId>'. If missing, confirm which database the API instance connects to and fix DATABASE_URL.
- Replay the webhook only after the order row exists, or let Stripe retry: fix the data first, then use Stripe CLI `stripe events retry evt_...` or the dashboard to redeliver.
- Check that migration 0023 (payment_order snapshot) was applied on every deployed instance; run `pnpm -F @proj-airi/api-server db:generate`/`db:push` or the drizzle migration step in deploy.
- If the receipt comes from a legacy stripe_checkout_session flow, ensure the legacy session row has a valid, backfilled payment_order id before settling.
- Do not fabricate order ids in tests or manual replays; create the order via openPending first.
Example fix
// before (manual replay of a stale webhook)
settle({ paymentOrderId: 'order-from-old-db', status: 'paid', ... })
// after (verify existence first)
const [order] = await db.select().from(paymentOrder).where(eq(paymentOrder.id, receipt.paymentOrderId))
if (!order) {
logger.warn('skipping settle: order missing', { orderId: receipt.paymentOrderId })
return // do not settle; investigate DB/env instead of throwing
} Defensive patterns
Strategy: try-catch
Validate before calling
const [order] = await db.select({ id: paymentOrder.id }).from(paymentOrder).where(eq(paymentOrder.id, receipt.paymentOrderId)).limit(1)
if (!order)
throw new Error(`order ${receipt.paymentOrderId} missing; skip settle`) Type guard
function isSettleableOrder(order: PaymentOrder | undefined): order is PaymentOrder {
return order != null && order.deletedAt == null
} Try / catch
try {
await payment.settle(receipt)
}
catch (error) {
if (errorMessageFrom(error)?.includes('Payment order not found')) {
logger.warn('settle skipped: order missing, likely stale/legacy webhook', { orderId: receipt.paymentOrderId })
return // do not retry until data is fixed
}
throw error
} Prevention
- Ensure migration 0023 (payment_order) is applied before deploying webhook settle code
- Reconcile legacy stripe_checkout_session rows to backfilled payment_order ids before replaying old webhooks
- Point all deployed API instances at the same DATABASE_URL/database
- Never hard-delete payment_order rows; use deletedAt soft delete as the code expects
- Add an integration test settling a receipt for a nonexistent order id
When it happens
Trigger: A Stripe webhook settles a receipt whose paymentOrderId is not present in payment_order: the order row was hard-deleted or never inserted (openPending insert failed before bindProcessorOrder), the receipt was reconstructed from a legacy stripe_checkout_session row that predates the payment_order table, or a test/manual replay uses a fabricated order id.
Common situations: Running an old database without the payment_order migration (0023) while new webhook code is deployed; replaying old Stripe webhook events from the dashboard after restoring or truncating tables; purging test orders while live webhooks still reference them; misconfigured environment pointing the API at a different database than the one that created the order.
Understand the failure class
Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.
Related errors
AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17).
Data as JSON: /api/errors/9d4f845c93cd54ef.
Report an issue: GitHub.
Appendix: source
Thrown at server/apps/api/src/services/domain/payment/index.ts:95
eq(schema.paymentCustomer.userId, userId),
eq(schema.paymentCustomer.processor, processor),
isNull(schema.paymentCustomer.deletedAt),
))
.limit(1)
return customer?.customerId
}
async function claimExistingOrder(receipt: ClaimReceipt): Promise<SettleResult> {
const result = await db.transaction(async (tx) => {
const [order] = await tx
.select()
.from(schema.paymentOrder)
.where(eq(schema.paymentOrder.id, receipt.paymentOrderId))
.for('update')
if (!order)
throw createInternalError('Payment order not found')
if (order.processor !== receipt.processor || (order.processorOrderId && order.processorOrderId !== receipt.processorOrderId))
throw createInternalError('Payment receipt does not match order')
if (order.deletedAt)
return { applied: false as const }
switch (receipt.status) {
case 'paid': {
if (order.status === 'paid')
return { applied: false as const }
if (order.status !== 'pending')
return { applied: false as const }
// NOTICE:
// Old and new replicas must claim the same retained checkout row.
// Migration 0023 is a snapshot; an old webhook can credit after it.View on GitHub (pinned to 438a067dde)