Skip to content

@paykernel/routing

Select-only gateway choice — deterministic first-match rules, money-safe amount ranges, and default-deny post-attempt fallback. Never auto-route after indeterminate.

Updated View as Markdown

@paykernel/routing chooses a gateway id. It does not execute payments and does not automatically switch gateways after timeout, indeterminate, or uncertain 5xx. Published as 0.1.1 on npm.

router.select is pure and sync. Pass decision.gateway into createPayment yourself.

Install

Depends only on @paykernel/core. Single export map: ".".

bun add @paykernel/routing @paykernel/core

Quickstart

import {
  createPaymentRouter,
  route,
} from "@paykernel/routing";

const router = createPaymentRouter({
  rules: [
    route({ currency: "SAR", paymentMethod: "mada" }).to("moyasar"),
    route({ currency: "USD" }).to("stripe"),
  ],
  fallback: "stripe", // select-time default only — NOT post-attempt recovery
});

const decision = router.select({
  currency: "SAR",
  paymentMethod: "mada",
});
// decision.gateway === "moyasar"
// decision.matched === true
// decision.usedFallback === false
// decision.reason === "rule_match"

If you pass Money, input.currency must match amount.currency or select throws NoRouteMatchError { reason: "currency_mismatch_honesty" }. Unconstrained fallback is not used.

With PaymentClient

import { createPaymentClient, money } from "@paykernel/core";
import { createPaymentRouter, route } from "@paykernel/routing";

const payments = createPaymentClient({
  gateways: {
    moyasar: /* moyasarGateway({...}) */,
    stripe: /* stripeGateway({...}) */,
  },
  defaultGateway: "stripe",
});

const router = createPaymentRouter({
  rules: [
    route({ currency: "SAR", paymentMethod: "mada" }).to("moyasar"),
    route({ currency: "USD" }).to("stripe"),
  ],
  fallback: "stripe",
});

async function createRoutedPayment(input: {
  amount: string;
  currency: string;
  paymentMethod?: string;
}) {
  const amount = money(input.amount, input.currency);
  const decision = router.select({
    currency: amount.currency,
    paymentMethod: input.paymentMethod,
    amount,
  });
  const gateway = decision.gateway;
  if (gateway !== "moyasar" && gateway !== "stripe") {
    throw new Error(`router selected unregistered gateway: ${gateway}`);
  }
  return payments.createPayment(
    {
      amount,
      currency: amount.currency,
      callbackUrl: "https://example.com/callback",
    },
    gateway,
  );
}

Select vs execute

router.select PaymentClient
Package @paykernel/routing @paykernel/core
Side effects None (pure, sync) Provider network I/O
Returns RoutingDecision Payment / operation result

The router never calls createPayment, capturePayment, refundPayment, or any fetch.

First-match

  1. Walk rules in array order.
  2. Skip input.excludeGateways and unhealthy gateways (input.health vs healthThreshold, default 1).
  3. Keep rules where ruleMatches is true.
  4. If merchantPreference is set and any candidate equals it (case-insensitive), restrict to those.
  5. If input.cost is provided, sort by ascending cost, then gateway id, then rule index.
  6. Otherwise pick the first candidate in original rule order.
  7. If no candidates: select-time fallback (still subject to exclude / health / capabilities / honesty).

Rule array order matters. Object-key iteration is never used.

decision.reason When
rule_match Matched rule; no preference/cost special-case
rule_match_merchant_preference Chosen gateway equals merchantPreference
rule_match_cost_tiebreak input.cost used for ranking
fallback Select-time fallback after no rule match

RoutingDecision.gateway is always set on success. On failure, select throws NoRouteMatchError (code: "no_route_match") — the library never invents a gateway id.

Amount ranges and honesty

Amount comparisons use core toMinorUnits (bigint) — never float.

route({
  currency: "USD",
  amountMin: "0.50",
  amountMax: "10000.00",
  amountCurrency: "USD",
}).to("stripe");

amountCurrency is required when either bound is set. Helpers: amountInRange, resolveInputAmount, compareDecimalAmounts.

Select-time fallback is blocked (honesty) when using it would lie about a configured partition:

NoRouteMatchError.reason Meaning
no_usable_fallback No rule match and no usable fallback
amount_range_honesty Input amount is outside a matching rule’s inclusive min/max
capability_honesty A matching rule requires capabilities the fallback lacks
currency_mismatch_honesty input.currency disagrees with Money / amountCurrency
complementary_currency_honesty Complementary currency partition (e.g. USD vs EUR rules)
complementary_country_honesty Complementary country partition
complementary_method_honesty Complementary payment-method partition
complementary_tenant_honesty Complementary tenant partition

isSelectHonestyReason(reason) is true for every honesty reason above except no_usable_fallback. Complementary splits are intentional fail-closed: after excluding the matching bucket, unconstrained fallback must not send EUR to the USD gateway.

Empty match {} is a catch-all rule (still subject to health / exclude / capabilities).

Post-attempt fallback (default-deny)

After you already tried a gateway, switching providers can double-charge if the first request may have been accepted.

import {
  classifySubmissionState,
  evaluateFallback,
  isSafeFallbackEligible,
} from "@paykernel/routing";

const state = classifySubmissionState({ errorKind: "timeout" });
// state === "timeout" → NOT safe

isSafeFallbackEligible("not_submitted"); // true
isSafeFallbackEligible("pre_submission_failure"); // true
isSafeFallbackEligible("timeout"); // false
isSafeFallbackEligible("indeterminate"); // false

const eligibility = evaluateFallback({ submissionState: state });
// eligibility.allowed === false for timeout without override
SubmissionState Auto-eligible?
not_submitted Yes
pre_submission_failure Yes
submitted No
indeterminate No
timeout No
connection_reset No
provider_5xx_uncertain No

classifyFromOperationOutcome:

Outcome State
indeterminate indeterminate
succeeded / requires_action / declined submitted
failed submitted (generic failed is not assumed pre-submit)

Never maps indeterminatepre_submission_failure.

classifySubmissionState fail-closes to indeterminate. Bare errorKind: "validation_error" is indeterminate (same class as invalid_request). Only a ValidationError-shaped object (name === "ValidationError" / code === "validation_error") is pre_submission_failure. Do not map a provider HTTP 400 onto errorKind: "validation_error".

AbortError / abort_error / ABORT_ERR classify as indeterminate (abort may race after provider accept). Known pre-submit cancel: errorKind: "aborted_before_submit" or "cancelled_before_submit".

trySelectFallbackGateway re-validates isSafeFallbackEligible and does not trust a forged { allowed: true } without an authentic evaluateFallback expert result (WeakSet brand).

Expert override (opt-in, never defaulted)

evaluateFallback({
  submissionState: "timeout",
  expertOverride: {
    confirmUnsafeFallback: true,
    reason: "provider confirmed no payment intent created",
  },
});

Bare true / empty reason is rejected. isExpertUnsafeFallbackOverride is the runtime guard. Denied paths throw UnsafeFallbackDeniedError (code: "unsafe_fallback_denied").

Compose after createPayment

import { createPaymentClient, money } from "@paykernel/core";
import {
  createPaymentRouter,
  route,
  classifySubmissionState,
  evaluateFallback,
  trySelectFallbackGateway,
} from "@paykernel/routing";

const payments = createPaymentClient({
  gateways: {
    moyasar: /* moyasarGateway({...}) */,
    stripe: /* stripeGateway({...}) */,
  },
  defaultGateway: "stripe",
});

const router = createPaymentRouter({
  rules: [
    route({ currency: "SAR", paymentMethod: "mada" }).to("moyasar"),
    route({ currency: "USD" }).to("stripe"),
  ],
  fallback: "stripe",
});

function assertRegistered(gateway: string): "moyasar" | "stripe" {
  if (gateway !== "moyasar" && gateway !== "stripe") {
    throw new Error(`router selected unregistered gateway: ${gateway}`);
  }
  return gateway;
}

async function charge(input: { amount: string; currency: string }) {
  const amount = money(input.amount, input.currency);
  const pay = {
    amount,
    currency: amount.currency,
    callbackUrl: "https://example.com/callback",
  };
  const decision = router.select({
    currency: amount.currency,
    amount,
  });
  const gateway = assertRegistered(decision.gateway);

  try {
    const result = await payments.createPayment(pay, gateway);
    if (result.outcome === "indeterminate" || result.reconciliationRequired) {
      // Typed post-submit uncertainty — do NOT select another gateway.
      return result;
    }
    return result;
  } catch (err) {
    const state = classifySubmissionState({ error: err });
    const eligibility = evaluateFallback({ submissionState: state });
    if (!eligibility.allowed) {
      throw err; // timeout-as-throw / submitted — do NOT auto-retry another gateway
    }
    const alt = trySelectFallbackGateway(
      router,
      { currency: amount.currency, amount },
      eligibility,
      { attemptedGateways: [decision.gateway] },
    );
    return await payments.createPayment(pay, assertRegistered(alt.gateway));
  }
}

If createPayment returns { outcome: "indeterminate" } without throwing, classify from that outcome (classifyFromOperationOutcome("indeterminate")) and do not select another gateway. Schedule reconciliation instead.

Telemetry

import {
  decisionToTelemetryAttributes,
  type RoutingDecision,
} from "@paykernel/routing";

declare const decision: RoutingDecision;
const attrs = decisionToTelemetryAttributes(decision);
// gateway, matched, usedFallback, reason, optional ruleIndex

Never includes tenantConfig, health/cost maps, secrets, or full RoutingInput dumps. Pass the same decision.gateway into createOperationContext (@paykernel/opentelemetry or core).

Failure paths

Error code When
NoRouteMatchError no_route_match No rule match and no usable select-time fallback (honesty reasons stay visible)
UnsafeFallbackDeniedError unsafe_fallback_denied Post-attempt path denied / no alternate

Guards: isNoRouteMatchError, isUnsafeFallbackDeniedError, isSelectHonestyReason. route().to("") throws (gateway id must be non-empty).

Runtime exports

createPaymentRouter, route, decisionToTelemetryAttributes, isSafeFallbackEligible, evaluateFallback, classifySubmissionState, classifyFromOperationOutcome, trySelectFallbackGateway, isExpertUnsafeFallbackOverride, ruleMatches, gatewayHasCapabilities, isGatewayHealthy, costScore, stringsEqualCi, amountInRange, resolveInputAmount, compareDecimalAmounts, NoRouteMatchError, UnsafeFallbackDeniedError, isNoRouteMatchError, isUnsafeFallbackDeniedError, isSelectHonestyReason.

See also: composition, outcomes, adapter selection.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close