road-runner-common 0.22.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Canonical Novu workflow trigger identifiers — the single Rust-side source of truth.
//!
//! A value here must match the workflow's trigger identifier in the notification center
//! **character for character**; an unknown identifier yields HTTP 422
//! `workflow_not_found`. The Java sender (`cex-keycloak`) and the notification service
//! (`cex-notification`) keep mirrored constants; all three must agree.
//!
//! Every identifier is the center's kebab-case slug. That is what the center generates
//! when a workflow is created and what it matches a trigger against; the
//! `Pascal_Snake_Case` text is only the workflow's display *name*. These constants used to
//! carry the names, so 53 of the 56 workflows here could never be triggered at all — each
//! answered 422 and was logged as one undelivered notification, which reads like an outage
//! rather than a typo, and nothing else gave it away.
//!
//! **Copy the identifier from the center; never derive it from the name.** It is not
//! reliably derivable: `Admin_Fiat_Unmatched_Deposit` is `fiat-unmatched-deposit`, and
//! `2FA_Enabled` is `2-fa-enabled`, not `2fa-enabled`. Add the constant here too, so no
//! service ever spells one out inline.

// --- Identity & security -------------------------------------------------------

/// Sensitive-action confirmation OTP (withdraw / api-key / 2FA-method change).
pub const ACTION_CONFIRMATION: &str = "action-confirmation";
/// Security alert when the account's second-factor method changes.
pub const SECURITY_TWO_FACTOR_CHANGED: &str = "security-two-factor-changed";
/// Two-factor authentication was turned on.
pub const TWO_FACTOR_ENABLED: &str = "2-fa-enabled";
/// Two-factor authentication was turned off, or a reset was requested.
///
/// The two are one message on purpose: from the account owner's side both mean "the second
/// factor protecting this account is no longer what it was", and that is the sentence
/// somebody who did not do it needs to read.
pub const TWO_FACTOR_DISABLED_RESET: &str = "2-fa-disabled-reset";
/// The account's e-mail address or phone number was replaced.
///
/// One workflow for both, with the payload naming which changed and showing the masked old
/// and new values — the sentence the owner needs is the same either way, and it is the one
/// an account takeover would try to outrun. Always delivered to the **old** address: the
/// new one has already been proven, the old one is the party that has to be warned.
pub const SECURITY_CONTACT_CHANGED: &str = "security-contact-changed";
/// A sign-in from a device this member has not used before.
pub const NEW_DEVICE_LOGIN: &str = "new-device-login";
/// A sign-in from a country the member has not signed in from before, or one that could
/// not plausibly follow their previous sign-in.
pub const NEW_IP_LOCATION_LOGIN: &str = "new-ip-location-login";
/// Pre-registration email verification OTP.
pub const REGISTER_EMAIL_VERIFICATION: &str = "register-email-verification";
/// Pre-registration phone verification OTP.
pub const REGISTER_PHONE_VERIFICATION: &str = "register-phone-verification";

// --- Identity verification (KYC) --------------------------------------------------
//
// Driven by the case log in cex-compliance: one workflow per fact the log can record, so
// which message a member gets is decided by the same event that moved their case.
//
//   application received → under review → approved
//                                       ↘ additional document requested (resubmit)
//                                       ↘ rejected (no retry path)

/// The member entered verification; the case now exists.
pub const KYC_APPLICATION_RECEIVED: &str = "kyc-application-received";
/// The case is with the provider or a compliance officer, awaiting a verdict.
pub const KYC_UNDER_REVIEW: &str = "kyc-under-review";
/// Verification succeeded.
pub const KYC_APPROVED: &str = "kyc-approved";
/// Rejected with **no** retry path — the member cannot fix this by resubmitting.
pub const KYC_REJECTED: &str = "kyc-rejected";
/// A nudge to a member who started verification and stopped, sent once per member and
/// track. Only for states where the next move is theirs — never for a case waiting on us.
pub const KYC_REMINDER: &str = "kyc-reminder";
/// Rejected **but retryable**: the provider asked for a document again (unreadable scan,
/// bad selfie, missing proof of address). A distinct message from [`KYC_REJECTED`]
/// because the member is being asked to act, not told the answer is no.
pub const KYC_ADDITIONAL_DOCUMENT_REQUESTED: &str = "kyc-additional-document-requested";

// --- Account standing ------------------------------------------------------------
//
// What the member may do, and who changed it. Distinct from KYC: verification is about
// who they are, these are about what the platform currently permits.

/// The account was disabled in Keycloak — the member cannot sign in.
///
/// Keycloak's `enabled` flag is the single source: it is what actually stops them, so a
/// freeze announced from anywhere else would promise an enforcement the platform does not
/// make. A KYC hold suspends *verification*, which is a different thing and stays silent.
pub const ACCOUNT_FROZEN: &str = "account-frozen";
/// The account was re-enabled in Keycloak.
pub const ACCOUNT_REACTIVATED: &str = "account-reactivated";
/// A specific permission was withdrawn (trading, crypto or fiat withdrawal) while the
/// account otherwise stays open. Narrower than [`ACCOUNT_FROZEN`], which suspends
/// everything gated on verification.
pub const ACCOUNT_RESTRICTED: &str = "account-restricted";

/// A withdrawal was requested from an unfamiliar device shortly after the credentials
/// changed — the shape of an account takeover. **Unwired:** cex-tx-risk-manager scores
/// `RECENT_PASSWORD_RESET`, `RECENT_2FA_CHANGE` and `NEW_DEVICE` independently, but nothing
/// composes them into a takeover verdict, and inventing that rule is a risk-policy decision
/// rather than a notification one.
pub const ACCOUNT_TAKEOVER: &str = "account-takeover";
/// The account was closed, by the member or by the platform. **Unwired:** no closure or
/// off-boarding flow exists in the platform — there is no endpoint, no status and no job
/// that closes an account.
pub const ACCOUNT_CLOSED: &str = "account-closed";

// --- Trading -----------------------------------------------------------------------
//
// Driven by the matching engine's own order-event stream, consumed by cex-history. Unlike
// the rest of this registry these scale with trading volume: an active trader produces
// hundreds a day and one resting order can produce dozens of partial fills. Configure the
// workflows accordingly — in-app rather than email/SMS unless the member opts in.

/// A new order passed validation and rests on the book.
pub const TRADE_ORDER_CREATED: &str = "trade-order-created";
/// An order matched in part; the remainder is still working.
pub const TRADE_ORDER_PARTIALLY_FILLED: &str = "trade-order-partially-filled";
/// An order matched in full and is closed.
pub const TRADE_ORDER_FILLED: &str = "trade-order-filled";
/// An order was cancelled by the member or by an admin. Distinct from expiry, which is an
/// order dying by its own time-in-force rules rather than anyone cancelling it.
pub const TRADE_ORDER_CANCELLED: &str = "trade-order-cancelled";

/// A stop or trigger order reached its trigger price. **Unwired:** the matching engine has
/// no stop orders — `OrderType` is `LIMIT` or `MARKET` only, and there is no trigger price
/// anywhere in it. Wiring this needs the engine feature first.
pub const TRADE_ORDER_TRIGGERED: &str = "trade-order-triggered";
/// Suspected wash trading, spoofing or market manipulation. **Unwired:** the engine
/// *prevents* self-trades per order (`RejectReason::SelfTradePrevented`) but nothing looks
/// for the pattern, and cex-trade-risk-manager is a pre-trade filter chain (balance, KYC,
/// notional, open orders) with no abuse detection. Deciding what counts as abuse is a
/// surveillance rule, not a notification one.
pub const TRADE_ABUSE: &str = "trade-abuse";

// --- Fiat withdrawal ------------------------------------------------------------
//
// The lifecycle, and which service owns each transition:
//   requested (ledger) → approved (ledger) → sent to bank (ledger)
//     → completed (history)                       … the bank settled it
//     → failed (history) → failed+refunded (ledger) … the bank returned it

/// The withdrawal request was accepted into the system and the balance reserved.
pub const FIAT_WITHDRAWAL_REQUESTED: &str = "fiat-withdrawal-requested";
/// An admin (or the automated decision) approved the request; the payout is next.
pub const FIAT_WITHDRAWAL_APPROVED: &str = "fiat-withdrawal-approved";
/// The bank accepted the payout instruction; settlement is now the bank's move.
pub const FIAT_WITHDRAWAL_SENT_TO_BANK: &str = "fiat-withdrawal-sent-to-bank";
/// The bank settled the transfer — the withdrawal is closed.
pub const FIAT_WITHDRAWAL_COMPLETED: &str = "fiat-withdrawal-completed";
/// The bank reported the payout as failed. The balance is **still reserved** at this
/// point — it goes back to admin review, so this is the "we are on it" message, not a
/// refund confirmation. The refund, if it happens, is a separate event below.
pub const FIAT_WITHDRAWAL_FAILED: &str = "fiat-withdrawal-failed";
/// A fiat withdrawal the bank returned or an admin rejected; the reserved balance
/// has been released back to available. Triggered from the ledger's withdraw
/// decision — the point where the release event is actually written — so the
/// message is never sent for a refund that did not happen.
pub const FIAT_WITHDRAWAL_FAILED_REFUNDED: &str = "fiat-withdrawal-failed-refunded";

// --- Fiat deposit ---------------------------------------------------------------

/// A bank movement was matched to the depositor and the amount is now on their
/// available balance.
pub const FIAT_DEPOSIT_CREDITED: &str = "fiat-deposit-credited";
/// A deposit an admin rejected or returned to the sender. Only reaches a customer when
/// the movement was matched to one — an unmatched rejection has no recipient and is
/// [`ADMIN_FIAT_UNMATCHED_DEPOSIT`]'s story instead.
pub const FIAT_DEPOSIT_REJECTED: &str = "fiat-deposit-rejected";

// --- Operations channels ---------------------------------------------------------
//
// Addressed to an `AdminChannel`, not to a person: routed to a topic whose membership
// lives in the notification center. The `Admin_` prefix in the identifier is the reminder
// that no customer ever receives these — which is also why they can fire at moments where
// no member has been identified yet.

/// A bank movement was ingested from the statement, before anything is known about who
/// sent it. Operations sees the platform's incoming flow as it lands.
pub const ADMIN_FIAT_DEPOSIT_DETECTED: &str = "fiat-deposit-detected";
/// An incoming amount held because the sender's name/IBAN did not match a member.
/// By definition no member was matched, so there is no subscriber to send to.
pub const ADMIN_FIAT_UNMATCHED_DEPOSIT: &str = "fiat-unmatched-deposit";

// --- Quorum governance (cex-policy) ----------------------------------------------
//
// Addressed to individual admins by their Keycloak subject rather than to a topic: who
// may vote is a property of the proposal (the roster allowed to act on that policy's
// action, minus the proposer), so the audience differs per proposal and cannot be a
// static channel membership.

/// A quorum proposal was opened and is waiting for votes. Sent to the admins eligible
/// to vote on it — a proposal nobody is told about is a proposal that expires.
pub const ADMIN_POLICY_CREATED: &str = "admin-policy-created";
/// A proposal reached its approval threshold and was applied.
pub const ADMIN_POLICY_APPROVED: &str = "admin-policy-approved";
/// A proposal was rejected: enough votes against that the threshold can no longer be met.
pub const ADMIN_POLICY_REJECTED: &str = "admin-policy-rejected";
/// A proposal ran out of time before reaching a decision. A distinct outcome from a
/// rejection: nobody decided against it, the window simply closed.
pub const ADMIN_POLICY_EXPIRED: &str = "admin-policy-expired";

// --- Registered in the notification center, no producer in the platform yet ------
//
// These exist as workflows but nothing triggers them, because the code has no point
// that means what they describe. Listed so the registry mirrors the notification
// center rather than silently diverging from it; delete or wire them deliberately.

/// A corporate onboarding application was received. **Unwired:** there is no KYB flow.
/// The only corporate concept in the codebase is the MKK investor *kind*, which is a
/// registry filing for an already-verified member, not an application to onboard.
pub const KYB_APPLICATION_RECEIVED: &str = "kyb-application-received";
/// A deposit held for risk/limit review. **Unwired:** the fiat deposit path has no
/// risk or limit gate — everything that reaches `PENDING_ADMIN` gets there through
/// identity matching, which is [`ADMIN_FIAT_UNMATCHED_DEPOSIT`].
pub const FIAT_DEPOSIT_MANUAL_REVIEW: &str = "fiat-deposit-manual-review";

// --- User bank accounts --------------------------------------------------------

/// A member added a bank account as a payout destination.
pub const BANK_ACCOUNT_ADDED: &str = "bank-account-added";

/// Several consecutive authentication failures were detected for a member.
pub const FAILED_LOGIN_WARNING: &str = "failed-login-warning";

/// An official legal or regulatory information request was recorded.
pub const LEGAL_REQUEST_RECEIVED: &str = "legal-request-received";

/// A member's password was changed successfully.
pub const PASSWORD_CHANGE: &str = "password-change";
/// A member signed in successfully.
pub const SUCCESSFUL_LOGIN: &str = "successful-login";

/// A member's withdrawal address whitelist was changed.
pub const WHITELIST_ADDRESS_CHANGE: &str = "whitelist-address-change";

pub const CRYPTO_ADDRESS_ADDED: &str = "crypto-address-added";
pub const CRYPTO_ADDRESS_DELETED: &str = "crypto-address-deleted";
pub const CRYPTO_DEPOSIT_CONFIRMED: &str = "crypto-deposit-confirmed";
pub const CRYPTO_DEPOSIT_CREDITED: &str = "crypto-deposit-credited";
pub const CRYPTO_DEPOSIT_DETECTED: &str = "crypto-deposit-detected";
pub const CRYPTO_DEPOSIT_MANUAL_REVIEW: &str = "crypto-deposit-manual-review";
pub const CRYPTO_DEPOSIT_UNCREDITED: &str = "crypto-deposit-uncredited";
pub const CRYPTO_WITHDRAWAL_APPROVED: &str = "crypto-withdrawal-approved";
pub const CRYPTO_WITHDRAWAL_BROADCASTED: &str = "crypto-withdrawal-broadcasted";
pub const CRYPTO_WITHDRAWAL_COMPLETED: &str = "crypto-withdrawal-completed";
pub const CRYPTO_WITHDRAWAL_FAILED_REJECTED: &str = "crypto-withdrawal-failed-rejected";
pub const CRYPTO_WITHDRAWAL_NO_TX: &str = "crypto-withdrawal-no-tx";
pub const CRYPTO_WITHDRAWAL_REQUESTED: &str = "crypto-withdrawal-requested";