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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

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)