# Checkmarble Integration Flow

How the **Banks API** talks to Checkmarble (Marble) today, and where it does **not** send Marble the full picture.

> **Reviewed:** `D:\Workspace\API\Banks`, branch `feature/marble-2026-09-18` (commit `010c0b49`), with the `ArthaMarble` 0.1.1 NuGet package (`AmlCompliance.Marble.dll`) decompiled to confirm exactly what goes over the wire. File and line references below are to that commit.

### At a glance

| | Today |
| --- | --- |
| Money-movement flows that call Marble | **2 of 7** — Quick Transfer withdrawals and Noah deposits |
| Transaction fields sent | **16 of 42** that the Marble model supports — and 6 of those carry a wrong or incomplete value |
| Customer or wallet records sent | **None** — `MarbleCustomer` and `MarbleWallet` exist in the package but are never used |
| Decisions ignored | **1 critical** — a Marble *Decline* on a settled Noah deposit still credits the wallet |
| Failed sends that are retried | **None** — ingest errors are logged and dropped |

## 1. The moving parts

```mermaid
flowchart LR
  subgraph API["Banks API"]
    QT["Quick Transfer<br/>withdrawal"]
    ND["Noah fiat deposit<br/>webhook"]
    NS["Noah settlement<br/>webhook"]
    ED["External deposit<br/>approval"]
    AD["Admin test endpoint<br/>POST api/marble/decide-all"]
    SVC["MarbleComplianceService<br/>tenant switch · worst outcome · fail-safe"]
  end
  PKG["ArthaMarble 0.1.1<br/>MarbleAmlProvider"]
  M[("Checkmarble<br/>Marble")]
  QT -->|decide + ingest| SVC
  ND -->|decide + ingest| SVC
  NS -->|decide + ingest| SVC
  ED -->|ingest only| SVC
  AD -->|decide| SVC
  SVC --> PKG
  PKG -->|"POST /v1/decisions/all"| M
  PKG -->|"POST /v1/ingest/transactions"| M
  subgraph NC["Never reach Marble"]
    X1["TazaPay · Openpayd · Mesta<br/>FiatRepublic · Artha deposits"]
    X2["FiatRepublic withdrawal<br/>crypto withdrawal · refunds"]
    X3["Customers · wallets<br/>admin approve / reject"]
  end
```

| Part | Where | What it does |
| --- | --- | --- |
| **ArthaMarble package** | NuGet `ArthaMarble` 0.1.1 | HTTP client for Marble. `DecideAllAsync` posts to `/{version}/decisions/all`; `IngestAsync` posts to `/{version}/ingest/{objectType}`. Authenticates with `X-API-KEY`. Ships `MarbleTransaction`, `MarbleCustomer` and `MarbleWallet` models. **Serializes with nulls omitted** — any field the app leaves empty is silently left out of the request |
| **MarbleComplianceService** | `ApplicationService/Aml/MarbleComplianceService.cs` | The only door to Marble. Checks the tenant has switched Marble on, runs every scenario for the object type, reduces the results to one gate, and never lets a Marble error crash a money flow |
| **Tenant switch** | Tenant setting `ComplianceCheck` | Marble runs only when set to `{"Provider":"Marble","IsEnable":1}`. Anything else — including a missing or malformed value — means Marble is **not called at all** |
| **Configuration** | `Program.cs` lines 133–160 | Base URL and API key come from the host's CoreConfig (provider `marble`), falling back to the `Marble` section of `appsettings.json` (`https://api.checkmarble.com`, `v1`, 30-second timeout). No secrets are stored in `appsettings` |
| **Admin test endpoint** | `Controllers/WebhookController.cs` line 461 | `POST api/marble/decide-all` runs a decision on any posted JSON. Requires login plus an admin permission |

## 2. How a decision becomes an action

`EvaluateAsync` sends the transaction to **every** Marble scenario for `transactions`, then takes the **worst** outcome. The score kept is the highest single-scenario score.

| Marble returns | Gate result | What the money flow does |
| --- | --- | --- |
| **Decline** or **Block and Review** | `Block` | Hold — do not pay out or credit |
| **Review** | `RequireApproval` | Hold for admin approval |
| **Approve** | proceed | Continue as normal |
| No scenario answered, or an unknown outcome | `RequireApproval` | Hold for admin approval |
| Marble error or timeout | `RequireApproval` (fail-safe) | Hold for admin approval — an outage never lets money through unreviewed |
| Marble switched off for the tenant | `NotEvaluated` | Continue as if Marble did not exist; nothing is recorded |

The outcome and score are written to the transaction row as `ComplianceStatus` and `ComplianceScore`.

## 3. The four flows that touch Marble

### 3.1 Quick Transfer withdrawal

`ApplicationService/WithdrawApplicationService.QuickTransfer.cs` — decision at lines 226–236, ingest at 256–260.

```mermaid
sequenceDiagram
  participant C as Customer
  participant API as Banks API
  participant FX as Rate feed
  participant M as Marble
  participant DB as Database
  participant P as Payout provider
  C->>API: Quick Transfer request
  API->>API: Validate, build withdrawal
  API->>FX: Convert amount to CAD
  API->>M: Decide (all "transactions" scenarios)
  M-->>API: Worst outcome + score
  alt Approve
    API->>DB: Save withdrawal
    API->>P: Dispatch payout (unless admin approval or Travel Rule applies)
  else Review, Block or Marble error
    API->>DB: Save as Submitted, AML status Pending — held
  end
  API->>M: Ingest the withdrawal (best effort)
```

### 3.2 Noah fiat deposit

`ApplicationService/WebhookApplicationService.NoahCallbacks.cs` — decision at lines 196–201, ingest at 233–234.

```mermaid
sequenceDiagram
  participant N as Noah
  participant API as Banks API
  participant M as Marble
  participant DB as Database
  N->>API: FiatDeposit webhook (Pending or Settled)
  API->>API: Build deposit row, convert amount to CAD
  API->>M: Decide (all "transactions" scenarios)
  M-->>API: Worst outcome + score
  alt Approve, no admin approval needed
    API->>DB: Approved — wallet credited (fiat)
  else Review or Marble error
    API->>DB: Submitted for admin approval — not credited
  else Decline or Block and Review
    API->>DB: Pending — not credited
  end
  API->>M: Ingest the deposit (best effort)
```

### 3.3 Noah settlement — the decision is taken, then ignored

Same file — decision at lines 633–637, promotion at 688–784, ingest at 641–644.

```mermaid
sequenceDiagram
  participant N as Noah
  participant API as Banks API
  participant M as Marble
  participant DB as Database
  N->>API: Transaction event (In, Settled)
  API->>DB: Store the locked FX rate
  API->>API: Stop if the crypto leg is already AML-pending
  API->>M: Decide again (second decision for the same deposit)
  M-->>API: Outcome — could be Decline
  rect rgb(253, 226, 225)
  Note over API,DB: Outcome is only recorded on the row — never checked
  API->>DB: Promote to Approved and credit the crypto wallet — always
  end
  API->>M: Ingest the fiat leg only (best effort)
```

### 3.4 External deposit approval

Same file, `ProcessExternalDepositAsync` (line 977). A pending deposit is matched to Noah by transaction hash, **promoted to Approved and credited without any Marble decision**, and only then ingested (lines 1062–1066). The crypto leg that is actually credited (`referenceTx`) is never sent.

## 4. What Marble actually receives

The two builders — `BuildMarbleWithdrawTransaction` (QuickTransfer.cs 1217–1238) and `BuildMarbleTransaction` (NoahCallbacks.cs 293–321) — are near copies and fill the same 16 fields.

### Sent today

| Marble field | Value sent | Problem |
| --- | --- | --- |
| `object_id` | Transaction id | — |
| `created_at`, `updated_at` | Row timestamps | — |
| `cust_id` | Internal customer id | Points at a customer Marble has never received (see MG-04) |
| `wallet_id` | Customer wallet id | Points at a wallet Marble has never received |
| `amount`, `fiat_value` | Amount converted to CAD | Becomes the **original** amount, still labelled CAD, when the rate is missing (MG-03) |
| `net_amount` | Same CAD amount | Fees ignored — should be amount minus fee |
| `transaction_amount` | Same CAD amount, as text | Code comment says "original currency" — the original is lost |
| `asset` | Always `"CAD"` | Original currency is lost |
| `direction` | `In` / `Out` | — |
| `status` | Always `"Completed"` | Wrong for held, pending, submitted and declined transactions (MG-05) |
| `transaction_type`, `transaction_subtype` | e.g. Withdraw / Fiat | — |
| `payee_name` | `SenderName` — for withdrawals the payee's **nickname** (`FavouriteName`) | Useless for name screening; the legal name is in `BeneficiaryName` / `HolderName` |
| `source` | `SenderAccountNumber` | For withdrawals this is the **beneficiary** account — belongs in `destination` |

### Supported by Marble, not sent — but the data exists in the Banks API

| Marble field | Where the data already is |
| --- | --- |
| `destination` | Withdrawals: `SenderAccountNumber` / payee `IBAN` / `AccNoorWalletAddress` |
| `fee` | `TransactionEntity.Fee` / `TotalComission` |
| `payment_method` | `TransactionEntity.PaymentType`, payee `PaymentType` — a code comment claims it is sent; it is not |
| `provider_reference` | `TransactionReferenceId` (Noah id) / `TxRef` — a code comment claims it is sent; it is not |
| `country` | `CustomerEntity.Country` (loaded in both flows) |
| `payee_bank_country` | `PayeeAccountEntity.BankCountry` (loaded in Quick Transfer) |
| `payee_aml_status` | `PayeeAccountEntity.Status` |
| `wallet_address` | `TransactionEntity.AccNoOrCryptoAddress` |
| `crypto_value` | `TransactionEntity.Volume` |
| `tx_hash` | External deposit request `TxHash` |
| `ip_address` | The customer's HTTP request in Quick Transfer |

### Supported by Marble, not available yet

| Marble field | Why it is empty | Requirement |
| --- | --- | --- |
| `risk_score` | No customer risk-rating model exists yet | GAP-06, FR-451 |
| `crypto_aml_score` | The KYT result goes to a Sumsub service-bus topic asynchronously and never comes back to Marble | FR-409 |
| `is_vasp_identified` | Travel Rule data stays in Sumsub | FR-605 |
| `device_*` (5 fields) | The Banks API does not capture device data | R-050 |
| `merchant_*` (5 fields), `batch_payment_id` | Not part of the Banks API flows | FR-208 |

## 5. Gaps

Ordered by severity. Each links to the requirement it breaks in the [Requirements](01-Requirements.md) tab.

### Critical

| ID | Gap | Where | Impact | Fix |
| --- | --- | --- | --- | --- |
| **MG-01** | **Settlement decision is ignored.** The Marble outcome is recorded but never checked; the deposit is always promoted and credited | NoahCallbacks.cs 634–639, 717–725 | A Marble *Decline* on a settled deposit still puts money in the customer's wallet (FR-504) | Check `aml.Block` / `aml.RequireApproval` before promoting; hold instead |
| **MG-02** | **Most money never reaches Marble.** Deposits via TazaPay, Openpayd, Mesta, FiatRepublic and Artha, the FiatRepublic withdrawal, the crypto withdrawal (`InsertWithdrawaCryptoTransaction`) and account-rejection refunds create transactions with no decision and no ingest | `WebhookApplicationService.*` (5 files), `AccountsApplicationService*.cs` | Unmonitored flows (FR-501). Marble's 24-hour totals and structuring rules undercount, so large-transaction reports can be missed (FR-551, R-002, R-003) | Route every transaction insert through one shared Marble step |
| **MG-03** | **Currency conversion fails open.** If the CAD rate is missing or the feed errors, the **original** amount is sent as if it were CAD | QuickTransfer.cs 1251–1265; NoahCallbacks.cs 479–501 | A GBP 8,000 withdrawal (about CAD 14,700) is scored as CAD 8,000 and slips under the CAD 10,000 rules (FR-554, FR-555) | Treat a missing rate as an evaluation failure → hold for approval, and send the rate, source and time |

### High

| ID | Gap | Where | Impact | Fix |
| --- | --- | --- | --- | --- |
| **MG-04** | **No customer or wallet records.** `MarbleCustomer` / `MarbleWallet` are never ingested, although `cust_id` is sent and a comment says it "must match the customer object ingested into Marble" | Whole solution | Scenarios cannot use customer type, country, age, KYC state or PEP status (FR-505, FR-452) | Ingest the customer on KYC / Noah customer events and on change; ingest wallets on creation |
| **MG-05** | **Status is always "Completed".** Held, pending, submitted and declined transactions are all sent as completed | QuickTransfer.cs 1231; NoahCallbacks.cs 314 | Aggregations count blocked money as completed; analysts see wrong state | Send the real `State` / `AdminState` |
| **MG-06** | **Lost sends are never retried.** `TryIngestAsync` logs and drops any error | MarbleComplianceService.cs 140–168 | One Marble hiccup permanently removes a transaction from Marble's history (FR-513, NFR-06, DQ-03) | Queue the ingest in the existing outbox / service bus and retry; reconcile daily against the ledger |
| **MG-07** | **Original currency and amount are dropped.** `asset` is forced to CAD and `transaction_amount` repeats the CAD figure | Both builders | No audit trail of what the customer actually moved, or at what rate (FR-554) | Keep CAD in `amount` for the scenarios; send the original amount and currency, plus the FX rate, source and time |
| **MG-08** | **Available fields are not mapped.** `fee`, `payment_method`, `provider_reference`, `destination`, `country`, `payee_bank_country`, `wallet_address`, `crypto_value`, `ip_address` — see section 4 | Both builders | Rules on method, geography, payee country, wallet or IP cannot be written (R-031, R-033, R-045) | One shared mapper that fills every field the entity already has |
| **MG-09** | **Payee identity is wrong.** `payee_name` is the payee's nickname and the beneficiary account is sent as `source` | QuickTransfer.cs 1060–1062, 1234–1235 | Payee screening and destination rules work on the wrong data (FR-301, FR-316) | Send `BeneficiaryName` / `HolderName`, and the account as `destination` |
| **MG-10** | **Admin decisions never go back to Marble.** Approving or rejecting a held withdrawal or deposit does not update Marble | `WithdrawApplicationService.StateChange.cs` and deposit approval paths | Marble's case view stays out of date; tuning has no outcome data (FR-859, FR-874) | Re-ingest the updated transaction on every admin state change |

### Medium

| ID | Gap | Where | Impact | Fix |
| --- | --- | --- | --- | --- |
| **MG-11** | **Duplicate decisions.** A re-delivered Noah webhook is decided again, and every Noah deposit is decided twice (deposit, then settlement) | NoahCallbacks.cs 196, 634 | Duplicate decisions and cases for one deposit (FR-854, DQ-02) | Skip the decision when the row already has a `ComplianceStatus` for that stage |
| **MG-12** | **30-second timeout** on a synchronous call inside a customer request and a webhook | `MarbleOptions.Timeout` default, `Program.cs` 160–161 | A slow Marble stalls withdrawals for up to 30 s (NFR-02: p99 under 2 s) | Set `TimeoutSeconds` to about 3; the fail-safe already holds on timeout |
| **MG-13** | **Only transactions are decided.** No onboarding decision and no name screening | Whole solution | Customers and payees are never screened in Marble (FR-303, FR-316) | Add customer and payee decisions once Marble Screening is licensed |
| **MG-14** | **Tests cover the on/off switch only.** The 12 Marble tests check tenant enablement; none check the payload or that a block actually holds money | `Banks.API.Tests/Tests/MarbleComplianceGateTests.cs` | MG-01 and MG-05 went unnoticed (T-03d, T-14) | Add payload-mapping tests and "Decline must not credit" tests per flow |
| **MG-15** | **Data residency unconfirmed.** The fallback base URL is Checkmarble's hosted service; the real value comes from CoreConfig | `appsettings.json`, `Program.cs` 143–145 | Customer data may leave Canada (NFR-12, A-02) | Confirm production CoreConfig points to the self-hosted Canadian instance |

### What is already done well

- **Fail-safe on outage.** A Marble error or timeout holds the money for approval instead of letting it through (FR-513, in part).
- **One door to Marble.** Every call goes through `MarbleComplianceService`, with a per-tenant switch that fails closed.
- **Verdict on the row.** `ComplianceStatus` and `ComplianceScore` give admins the Marble result on each transaction.
- **Secrets are kept out of the code.** API keys come from CoreConfig, and the test endpoint is admin-only.

## 6. Target: one shared mapper

MG-03, MG-05, MG-07, MG-08 and MG-09 can all be fixed in one place: a single mapper used by every flow, replacing the two duplicated builders. A sketch using only fields that exist today on `TransactionEntity`, `CustomerEntity` and `PayeeAccountEntity`:

```csharp
public static MarbleTransaction ToMarble(
    TransactionEntity txn, CustomerEntity? customer, PayeeAccountEntity? payee,
    CadValue cad, string direction, string? ipAddress)
{
    bool outbound = direction == BanksConstants.DirectionOut;
    return new MarbleTransaction
    {
        ObjectId           = txn.Id.ToString(),
        CreatedAt          = ToUtc(txn.CreatedDate),
        UpdatedAt          = ToUtc(txn.ModifiedDate ?? txn.CreatedDate),
        CustId             = txn.CustomerId?.ToString(),
        WalletId           = txn.CustomerWalletId?.ToString(),

        Amount             = cad.Amount,                    // scenarios stay in CAD
        FiatValue          = cad.Amount,
        Fee                = txn.TotalComission ?? txn.Fee,
        NetAmount          = cad.Amount - cad.ToCad(txn.TotalComission ?? txn.Fee ?? 0m),
        Asset              = "CAD",
        TransactionAmount  = $"{txn.Amount} {txn.TxWallet}", // original amount + currency
        CryptoValue        = txn.Volume,

        Direction          = direction,
        Status             = txn.State,                     // real state, not "Completed"
        TransactionType    = txn.TxType,
        TransactionSubtype = txn.TxSubType,
        PaymentMethod      = txn.PaymentType ?? payee?.PaymentType,
        ProviderReference  = txn.TransactionReferenceId ?? txn.TxRef,

        PayeeName          = payee?.BeneficiaryName ?? payee?.HolderName ?? txn.SenderName,
        PayeeBankCountry   = payee?.BankCountry,
        PayeeAmlStatus     = payee?.Status,
        Source             = outbound ? null : txn.SenderAccountNumber,
        Destination        = outbound ? payee?.IBAN ?? txn.SenderAccountNumber : null,
        WalletAddress      = txn.AccNoOrCryptoAddress,
        Country            = customer?.Country,
        IpAddress          = ipAddress,
    };
}
```

`CadValue` carries the converted amount together with the rate, its source and its timestamp. When no rate is available it fails the evaluation (hold for approval) rather than passing the original amount through. The rate, source and timestamp — and any other attribute Marble has no field for, such as `purpose` — need new fields in Marble's data model **and** in the `ArthaMarble` package (a 0.1.2 release), because `MarbleTransaction` is sealed.

## 7. Suggested order of work

| Step | Fixes | Why first |
| --- | --- | --- |
| 1 | **MG-01** — honour the settlement decision; **MG-03** — hold on a missing rate | Small code changes that stop money moving against a Marble verdict or on a wrong amount |
| 2 | **MG-05, MG-07, MG-08, MG-09** — one shared mapper (section 6) | One change fixes most of the data quality |
| 3 | **MG-02** — call the mapper from every provider flow | Gives Marble the full transaction history its aggregate rules depend on |
| 4 | **MG-04** — ingest customers and wallets | Unlocks customer-level rules and, later, screening (MG-13) |
| 5 | **MG-06, MG-10, MG-11** — outbox with retries, sync admin decisions, stop duplicates | Makes Marble's copy complete and trustworthy |
| 6 | **MG-12, MG-14, MG-15** — timeout, tests, residency check | Hardening and evidence for examiners |
