Skip to content

[Bug]: Admin "Check status" (authorizePaymentSessionForOrderWorkflow) creates a capture but never emits payment.captured #16727

Description

@mczech-tsh

Package.json file

{
  "name": "medusa-payment-captured-repro",
  "version": "0.0.1",
  "private": true,
  "license": "MIT",
  "scripts": {
    "build": "medusa build",
    "start": "medusa start",
    "dev": "medusa develop",
    "test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit"
  },
  "dependencies": {
    "@medusajs/admin-sdk": "2.20.1",
    "@medusajs/cli": "2.20.1",
    "@medusajs/framework": "2.20.1",
    "@medusajs/medusa": "2.20.1"
  },
  "devDependencies": {
    "@medusajs/test-utils": "2.20.1",
    "@swc/core": "^1.7.28",
    "@swc/jest": "^0.2.36",
    "@types/jest": "^29.5.14",
    "@types/node": "^20.12.11",
    "jest": "^29.7.0",
    "pg-god": "^1.0.12",
    "ts-node": "^10.9.2",
    "typescript": "^5.6.2"
  },
  "engines": {
    "node": ">=20"
  }
}

Node.js version

v24.13.1

Database and its version

PostgreSQL 18

Operating system name and version

macOS Tahoe 26.5

Browser name

N/A (backend behaviour; reproduced via the admin dashboard and via the API directly)

What happended?

Since v2.17.0 (#15085, async payment methods) a payment provider can return pending_authorization from
authorizePayment, the cart completes with an "awaiting" order, and an admin can later click Check status
on the order page. That button calls POST /admin/orders/:id/payment-sessions/authorize, which runs
authorizePaymentSessionForOrderWorkflow.

When the provider now reports the payment as captured (which is what @medusajs/payment-stripe does:
getStatus() maps a succeeded PaymentIntent to PaymentSessionStatus.CAPTURED, and authorizePayment
delegates to getPaymentStatus), the workflow:

  1. runs authorizePaymentSessionStepPaymentModuleService.authorizePaymentSession treats CAPTURED as
    authorized + auto-captured (the is_captured path from fix(payment): Double idempotent capture called with auto capture beha… #14073), creates the Payment and a Capture row,
    sets captured_at;
  2. runs addOrderTransactionStep for those captures;
  3. returns.

The order's payment_status becomes captured and the payment collection is completed, but
payment.captured is never emitted. In core, that event is emitted only by emitEventStep inside
capturePaymentWorkflow (packages/core/core-flows/src/payment/workflows/capture-payment.ts), and
authorizePaymentSessionForOrderWorkflow
(packages/core/core-flows/src/payment/workflows/authorize-payment-session-for-order.ts) never invokes it.

Every other place where core materialises a capture does go through capturePaymentWorkflow, so the event
fires even when the provider auto-captured and the provider call is skipped:

  • processPaymentWorkflow, capture-payment-autocapture branch (authorizePaymentSessionStep immediately
    followed by capturePaymentWorkflow.runAsStep);
  • processPaymentWorkflow, capture-payment branch;
  • markPaymentCollectionAsPaidWorkflow;
  • POST /admin/payments/:id/capture.

The admin "Check status" path is the only one that creates a capture silently. That matters because the
button exists precisely for the case where the provider's webhook did not arrive, so there is no later
processPaymentWorkflow run to emit the event. Anything subscribed to payment.captured (fulfilment of
digital goods, invoicing, notifications, tax reporting, etc.) never runs for such orders.

This is the same class of problem as #9252 (webhook route not emitting payment.captured), which was fixed
in core rather than delegated to providers. Providers cannot work around it: AbstractPaymentProvider
returns only { status, data } and has no access to the event bus.

Related: #15085 introduced the workflow; #14073 made authorizePayment → CAPTURED an officially supported
provider pattern; #16722 is currently hardening the neighbouring autocapture branch of
processPaymentWorkflow.

Expected behavior

POST /admin/orders/:id/payment-sessions/authorize should emit payment.captured whenever the
authorization results in a captured payment, exactly as the webhook path does. The simplest fix mirrors the
capture-payment-autocapture branch of process-payment.ts: after authorizePaymentSessionStep, run
capturePaymentWorkflow.runAsStep({ input: { payment_id: payment.id } }) when the returned payment has
captured_at set (or captures). The module already short-circuits in capturePayment_ when captured_at
is set and skips the provider call, and addOrderTransactionStep dedupes by reference_id, so this is safe
to run after the existing transaction step. Alternatively, emit PaymentEvents.CAPTURED with
{ id: payment.id } directly.

Actual behavior

After the button click the order shows payment_status: "captured",
payment_collections[0].payments[0].captures has one row, payment_collections[0].status is completed,
and no payment.captured event is published. If the provider's payment_intent.succeeded /
checkout.session.completed webhook is later redelivered, processPaymentWorkflow runs
capturePaymentWorkflow and the event fires at that point, which confirms that only the admin path is
missing it.

Verified against the installed @medusajs/core-flows@2.17.2 dist (no emitEventStep or
capturePaymentWorkflow reference in authorize-payment-session-for-order.js) and against the current
develop source; the file has not changed since #15085.

Link to reproduction repo

https://github.com/mczech-tsh/medusa-payment-captured-repro/

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions