Skip to content

Outcomes

Discriminate on outcome and isPaidOutcome — never fulfill on API-ok or success.

Updated View as Markdown

Prefer outcome discrimination after createPayment / capturePayment / getPayment. Hosted checkout, customers, disputes, and payment links are outcome-only — they have no success boolean. Checkout create success is not paid; use isHostedCheckoutRedirect then fulfill from payment settlement.

success: boolean was removed in 1.0. Never branch on success. Use isPaidOutcome / outcome === "succeeded" with paid-like status (paid only).

Why not API-ok alone?

Historically gateways set success: true when the HTTP/API call completed without a transport failure — including pending, 3DS / requires_action, and authorized holds. That is not the same as “customer paid; fulfill the order.”

Signal Meaning
success: true (removed in 1.0) API call OK — deprecated for fulfillment
outcome: "succeeded" Operation completed in a terminal success sense (may still be auth-only — check status)
outcome: "requires_action" Customer must complete 3DS, redirect, OTP, or client SDK confirm
outcome: "declined" Definitive issuer/provider decline
outcome: "failed" Definitive failure
outcome: "indeterminate" Uncertain after submit — must reconcile; never treat as decline or paid

Fulfill only when money is settled:

import {
  isPaidOutcome,
  isRequiresActionOutcome,
  isIndeterminateOutcome,
  mapGatewayResultToOperationResult,
} from "@paykernel/core";

const result = await client.createPayment(params);

if (isPaidOutcome(result)) {
  // status paid + outcome succeeded (approved/authorized are never paid)
  await fulfillOrder(result.gatewayId);
} else if (isRequiresActionOutcome(result)) {
  // redirect / OTP / Stripe confirm — do not fulfill
} else if (isIndeterminateOutcome(result)) {
  // do NOT mark failed; reconcile with getPayment / webhooks
  // do NOT createPayment again
}

isPaidOutcome is true only when outcome === "succeeded" and paid-like status (paid only). Auth holds (authorized), buyer approval (approved), pending, requires_action, declined, failed, and indeterminate all return false. reconciliationRequired: true always returns false even if outcome/status look settled.

Post-submit transport: createPayment / capturePayment / refundPayment / voidPayment no longer throw NetworkError when the mutating HTTP request may already have been accepted (timeout, connection drop, or 5xx after POST). BaseGateway returns outcome: "indeterminate" + reconciliationRequired: true. Preflight auth and GET still throw NetworkError. Caller abort before submit still throws PaymentAbortedError. Caller abort after a mutating POST maps to NetworkError with afterProviderSubmit: true.

PaymentOperationResult arms

Preferred union (via mapGatewayResultToOperationResult):

type PaymentOperationResult =
  | { outcome: "succeeded"; payment: Payment }
  | { outcome: "requires_action"; payment: Payment; action: PaymentAction }
  | { outcome: "declined"; failure: PaymentDecline; payment?: Payment }
  | { outcome: "failed"; error: PaymentErrorLike; payment?: Payment }
  | {
      outcome: "indeterminate";
      reconciliationRequired: true;
      providerRequestId?: string;
      payment?: Payment;
      message?: string;
    };

Succeeded (paid):

const op = mapGatewayResultToOperationResult(result, { gateway: "stripe" });
if (op.outcome === "succeeded" && op.payment.status === "paid") {
  await fulfillOrder(op.payment.references.providerObjectId);
}
// Auth hold: outcome can be "succeeded" with status "authorized" — isPaidOutcome is false
// Partial capture: bare status "partially_captured" infers requires_action (open money).
// Successful void: outcome "succeeded" + status "cancelled" stays succeeded on
// map/infer (not coerced to failed); isPaidOutcome remains false.

Requires action (3DS / redirect):

if (op.outcome === "requires_action") {
  // op.action: { type: "redirect", url } | use_stripe_sdk | stcpay_otp | …
  return respondWithNextAction(op.action);
}

Declined:

if (op.outcome === "declined") {
  // op.failure.code / message — not a transport error
  return showDecline(op.failure);
}

Indeterminate (must reconcile):

if (op.outcome === "indeterminate") {
  // op.reconciliationRequired === true always
  await scheduleReconciliation(op.payment?.references);
  // Never mark order failed or paid from this arm alone
}

Helpers (1.0)

Helper Role
isPaidOutcome(result) outcome === "succeeded" and paid-like status (paid only; not approved / authorized)
isRequiresActionOutcome(result) Customer action required
isIndeterminateOutcome(result) Explicit indeterminate / must reconcile
mapGatewayResultToOperationResult(result) Gateway shape → preferred union
applyOutcomeToGatewayResult(base, outcome) Write outcome + references (and reconciliationRequired for indeterminate)
inferOperationOutcome(result) Infer when gateway has not set outcome yet
buildProviderReferences(input) Structured provider IDs

applyOutcomeToGatewayResult writes outcome + references — no success. successFromOutcome / successFromRefundOutcome were removed in 1.0.

Throw vs outcome

Aligned with Engineering Rule 3 (uncertain outcomes must not become failure):

  1. Pre-submit / validation / auth config may throw (InvalidRequestError, GatewayNotConfiguredError, …).
  2. Transport errors before the provider may have accepted the mutation may throw (NetworkError).
  3. After submit is ambiguous (timeout after request may have been accepted, unknown idempotency replay): return outcome: "indeterminate" with reconciliationRequired: true. Do not map to failed / decline.
  4. Hard declines may appear as outcome: "declined" or as thrown CardDeclinedError / InsufficientFundsError. Integrators should handle both until gateways fully migrate to outcome arms.

The testkit encodes this:

  • { outcome: "timeout" } / { outcome: "network_error" } → throw NetworkError
  • { outcome: "indeterminate" } → result with outcome: "indeterminate", reconciliationRequired: true
  • { outcome: "provider_ok_client_timeout" } → provider-side paid success retained; client throws NetworkError

Common inputs vs provider extensions (1.0)

CommonPaymentInput is the shared create shape without provider keys:

type CommonPaymentInput = {
  amount: AmountInput; // money("10.50", "SAR")
  orderId?: string;
  description?: string;
  metadata?: PaymentMetadata;
};

CreatePaymentParams is closed in 1.0: only CommonPaymentInput + currency + callbackUrl + capture / idempotencyKey / customerId / paymentMethodId / offSession. Provider fields live on per-gateway MoyasarCreatePaymentParams, StripeCreatePaymentParams, PayPalCreatePaymentParams, PaymobCreatePaymentParams via createPaymentClient registry. tokenId was removed (use moyasarSource: { type: "token", token }).

Union Use for
PaymentDomainStatus (= PaymentStatus) Charge / intent lifecycle (pending, paid, …)
AuthorizationStatus Auth holds
CaptureStatus Capture lifecycle
RefundDomainStatus (= RefundStatus) Refund objects (pending, completed, failed)
SetupTokenStatus Setup / vault
WebhookEnvelopeStatus WebhookEvent.status (PaymentDomainStatus | RefundDomainStatus | SetupTokenStatus)
GatewayPaymentStatus GatewayPaymentResult.status — envelope plus legacy refund_* / setup_completed aliases for gateway internal mapping
DisputeStatus Disputes
import { buildProviderReferences } from "@paykernel/core";

const references = buildProviderReferences({
  gateway: "paypal",
  gatewayId: orderId,
  status: "pending",
  orderId,
  captureId,
  authorizationId,
  providerNativeStatus: "CREATED",
});
Field Meaning
providerObjectId Primary provider object id (also dual-written as gatewayId)
providerRequestId Provider request / correlation id when available
internalReference Merchant order / internal correlation
parentId Parent resource when this is a child
relatedIds order / capture / authorization / refund / charge / customer
providerNativeStatus Unnormalized provider string
normalizedStatus SDK-normalized status
gateway Gateway id

Refund outcomes (1.0)

Refunds use RefundOperationOutcome / RefundOperationResult via mapGatewayRefundToOperationResult:

outcome Typical status
succeeded completed
pending pending
failed failed
indeterminate (ambiguous) + reconciliationRequired: true
Helper Role
applyOutcomeToGatewayRefundResult(base, outcome) Write outcome (+ reconciliationRequired when indeterminate)
inferRefundOperationOutcome(result) Infer / coerce when branching on refund outcomes
mapGatewayRefundToOperationResult(result) Gateway refund shape → preferred refund union

Bare refund status (no explicit outcome, no recon flag): completed infers succeeded, pending infers pending (not indeterminate), failed infers failed. Do not treat a pending refund as settled. Ambiguous only when reconciliationRequired or an explicit indeterminate marker is set.

applyOutcomeToGatewayResult / inferOperationOutcome coerce stored outcome against status:

Input Inferred / stored outcome
Bare payment refund_completed / refund_pending requires_action (open / incomplete refund snapshot — not indeterminate)
Bare payment reversed failed
outcome: "succeeded" + status: "pending" / "processing" / "approved" / "partially_captured" / "refund_completed" / "refund_pending" requires_action
outcome: "succeeded" or "requires_action" + status: "failed" / "refund_failed" declined if decline is present, else failed
Refund outcome: "failed" + gateway status: "completed" succeeded (status wins)

Post-submit create / OTP / capture / refund / void timeouts return outcome: "indeterminate" with gatewayId taken from params when present (gatewayPaymentId, orderId, transactionUrl, idempotencyKey, …). Create without any of those ids still uses gatewayId: "unknown" because the provider has not assigned an object id — reconcile via the idempotency store / inquiry, not getPayment("unknown").

After-hook freeze

Money/identity fields restored after after-hooks include (when present): outcome, status, amount, gatewayId, capture/order/authorization/refund IDs, fees, capturedAmount, refundedAmount, clientSecret, references, decline, reconciliationRequired, providerRequestId. Restore runs between composed after-hooks as well as on the client return path, so a later handler cannot see a previous hook’s forged paid/status/amount. After-hooks cannot flip a paid result into declined or invent a paid status.

Migration checklist (0.x apps)

  1. Fulfillment: replace if (result.success) with if (isPaidOutcome(result)).
  2. 3DS / redirect: branch on isRequiresActionOutcome(result) — do not treat as paid.
  3. Declines: handle both thrown CardDeclinedError / InsufficientFundsError and outcome: "declined".
  4. Timeouts / ambiguous mutations: never map to “order failed” without reconciliation; prefer isIndeterminateOutcome / reconciliationRequired.
  5. IDs: read result.references?.providerObjectId when available; keep reading gatewayId / orderId / captureId for dual-write compatibility.
  6. Inputs: prefer money("10.50", "SAR") and provider-typed create params.
  7. Tests: use @paykernel/testkit mockGateway — scripted outcomes dual-write Phase 6 fields.

See Getting started for the reconcile worker, Webhooks for paid rematch after claim, and Migrate to 1.0.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close