Wallet
Spend Policy

Spend Policy

Who calls these endpoints?Merchant panel
Base path
/api/panel/v1/wallet
Identity
Panel session (JWT) — Authorization: Bearer <panel access token>. The token comes from panel login; an s2s signing key does NOT open these endpoints.
Authorization
RBAC permission: wallet:limit:manage · wallet:enduser:manage
Cross-surface
The endpoints on this page have NO equivalent in the s2s API; you cannot call them from your own panel. These capabilities exist only in the merchant panel, behind a panel session.

Spend policy constrains money leaving a wallet with a set of rules: on which channel, in which category, at which merchant and during which hours it may be spent.

Its most common use is the corporate wallet — a meal card that only works in restaurants, during working hours, at specific merchants. But the engine is not corporate-specific: rules can also be defined at program scope.

Spend policy is managed from the panel only; the signed s2s surface has no endpoint to read or write rules. Rules are evaluated automatically by the engine at operation time.

Endpoints

GET spend-policy-categories            # category catalog
GET spend-policy-operations            # constrainable operations + merchantBearing
GET mcc-categories                     # real MCC → category mapping (read-only)
GET programs/:id/spend-policies        # list rules
PUT programs/:id/spend-policies        # write rules (upsert)
PUT end-users/:id/merchant-category    # assign a category to a merchant

The rule model

A rule states two things: where it applies and what it does.

FieldDescription
modeALLOW_LIST (permit only these) or DENY_LIST (block these)
corporateAccountIdSet ⇒ corporate scope; empty ⇒ program scope
operations[]Operation/channel axis
categories[]Category axis
merchantEndUserIds[]Merchant axis
timeWindows[] + timeZoneTime-window axis
statusACTIVE | INACTIVE
descriptionAudit note (max 240)
⚠️

The mode values are ALLOW_LIST and DENY_LIST — not ALLOW / DENY. There is no separate "effect" field; the list mode carries the effect.

A rule must constrain at least one axis. A rule that leaves all four empty is rejected at the database level — a rule that constrains nothing is either meaningless or (in ALLOW mode) permits everything.

The four axes

1. Operation / channel

Constrainable operations (read them from GET spend-policy-operations — do not copy the list into your own code):

W2W_TRANSFER · W2IBAN_WITHDRAW · WITHDRAW_BANK · PAYMENT_BUY · BILL_PAY · CASHOUT_ATM · CASHOUT_QR · CRYPTO_SWAP · CARD_AUTH · BULK_PAYOUT_ITEM_IBAN

Card authorization (CARD_AUTH) is wired into this engine too. On the card path a policy denial produces a terminal DECLINED record, not an exception: an issuer webhook would read a thrown error as a transient fault and retry it.

A bulk-payout item targeting an internal wallet (BULK_PAYOUT_ITEM_WALLET) is deliberately absent from the list — it is a funding movement, not spending.

2. Category

The catalog is fixed and read from GET spend-policy-categories:

FOOD_BEVERAGE · GROCERY · FUEL · TRANSPORT · HEALTH · PHARMACY · EDUCATION · CLOTHING · ELECTRONICS · ENTERTAINMENT · TRAVEL · STATIONERY · SERVICES · OTHER

A category is assigned to a merchant with PUT end-users/:id/merchant-category.

⚠️

The category axis runs only on PAYMENT_BUY today. No other operation has a merchant carrying a category (an IBAN withdrawal, a bill payment or an ATM cash-out has no "restaurant" behind it).

This cuts both ways, and neither direction is silent:

  • An ALLOW_LIST rule constraining categories does not free a channel that carries no category — that channel still contributes to the default deny.
  • A DENY_LIST rule constraining categories does not block a channel that carries no category.

If you want "the meal card only works in restaurants", pair the category rule with the operation axis as well; otherwise you will believe you closed IBAN withdrawals when you did not.

Which operations actually run the category/merchant axes is reported by the merchantBearing field in the GET spend-policy-operations response.

Real MCC mapping

The categories above are not MCCs, but real network MCCs (ISO 18245, 4 digits) are mapped onto them. The mapping table is read with GET mcc-categories and is shown in the panel while picking categories:

[
  { "mcc": "3000-3299", "category": "TRAVEL", "isRange": true },
  { "mcc": "5411", "category": "GROCERY", "isRange": false },
  { "mcc": "5812", "category": "FOOD_BEVERAGE", "isRange": false }
]

Each row is either a single code or an ISO block range (isRange). The mapping is a single source of truth platform-wide: the same table also feeds fraud rules.

⚠️

An MCC that is not in the table maps to no category — it is deliberately not downgraded to OTHER.

OTHER means "an operator looked and said other", and it passes when added to an ALLOW_LIST. Treating an unclassified MCC as OTHER would silently let every merchant you don't recognise through your allow-list. Unknown MCC = no category ⇒ the category axis behaves fail-closed.

The category axis does not run on card authorization (CARD_AUTH) today — a real MCC will start flowing on the card path with the issuer integration; until then card spending is constrained only by the operation axis and by limits. The mapping table is ready and waiting: once card MCCs flow, they resolve onto the same categories.

3. Merchant

merchantEndUserIds[] lists the merchant end-user ids the constraint applies to. It is subject to the same merchantBearing rule as the category axis.

4. Time window

A window is defined by ISO weekday (1 = Monday … 7 = Sunday) and an intraday [start, end) range. Windows within one rule are OR-ed: if any of them matches, the axis passes.

🚫

Every rule carries its own IANA timeZone, and it is REQUIRED whenever windows are present. There is NO default.

There is no fallback to the server's zone, the user's device or UTC. The same absolute instant produces a different decision under two rules with different timeZone values — and that is the intent: one company runs on Istanbul office hours, another on London's.

A row with windows but no time zone is evaluated fail-closed (the axis does not pass). There is no silent UTC assumption.

A valid IANA identifier such as Europe/Istanbul is required; an invalid one is rejected at write time. Clearing the windows clears the time zone too.

Evaluation rules

Within a rule: AND

All constrained axes of a rule must hold at the same time. Operation, category, merchant and time are checked in order; the rule stops matching at the first axis that fails.

An axis that was not constrained (not supplied) is not checked.

Across rules: DENY beats ALLOW

⚠️

DENY_LIST always wins, and there is no specificity precedence. Every DENY_LIST rule runs before any ALLOW_LIST rule is evaluated. A narrow merchant-based DENY_LIST beats a broad category-based ALLOW_LIST — there is no "the more specific rule wins" logic.

Across scopes: INTERSECTION

There are two scopes: program (corporateAccountId empty) and corporate (corporateAccountId set). For a given user, only the program's rules and their own company's rules are loaded.

🚫

ALLOW_LIST rules within the same scope union (OR); different scopes INTERSECT (AND).

Every scope that has ALLOW_LIST rules must produce at least one match. A company therefore cannot widen the program operator's narrow ALLOW list — a narrower scope can never loosen a broader one, only tighten it further.

With a single pool, a company writing a broad ALLOW rule would have removed the boundary set by the program operator.

"No policy" is NOT the same as "empty list"

🚫

Without this distinction spelled out, operators do the exact opposite of what they intend:

  • With zero ACTIVE rules the engine is INERT. No axis is even resolved; every operation passes. An INACTIVE rule counts as absent, and another program's rule does not bind.
  • An empty array ([]) does NOT mean "unconstrained" — it means "matches nothing" and reads fail-closed. An ALLOW_LIST rule with an empty list never matches ⇒ everything in that scope is denied.

Writing an empty array is already refused at three layers (request validation, service, database constraint) — but knowing the behaviour prevents the "let me clear the list so the constraint goes away" reflex. To remove a constraint, set the rule INACTIVE or delete it; do not empty its list.

An unresolved axis is fail-closed

If an operation carries a category/merchant but the value cannot be resolved and at least one rule constrains that axis, the operation is denied with CATEGORY_UNRESOLVED / MERCHANT_UNRESOLVED. If no rule constrains that axis, there is no denial.

Denial reasons: DENIED_BY_RULE · NO_MATCHING_ALLOW_RULE · CATEGORY_UNRESOLVED · MERCHANT_UNRESOLVED.

Where the engine sits in the chain

Spend policy runs alongside the KYC and capability gates, before fee quoting and limit reservation — that is, before any money is held. A denied operation never blocks a balance.

Do not confuse it with limits: a limit answers "how much", spend policy answers "where / when / on which channel". They are separate engines and both must pass. See Limits.

Checklist

  • Category rules are paired with an operation axis (otherwise only PAYMENT_BUY is affected)
  • Every time-window rule states its timeZone explicitly
  • Constraints are removed by setting the rule INACTIVE, never by emptying the list
  • Your product design assumes corporate rules cannot loosen program rules
  • Operators have been told that DENY_LIST always wins
  • A policy denial is shown to the user with its real reason, not as "insufficient balance"